diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index c57b43f9cc..7c2ad4c390 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -30,7 +30,7 @@ App > Screen { overflow-y: auto; height: 20; margin: 1 2; - background: $panel; + background: $surface; padding: 1 2; } diff --git a/sandbox/will/tree.py b/sandbox/will/tree.py index 75b1ef2838..13df2604c3 100644 --- a/sandbox/will/tree.py +++ b/sandbox/will/tree.py @@ -5,16 +5,10 @@ class TreeApp(App): - DEFAULT_CSS = """ - Screen { - overflow: auto; - - } - """ - def compose(self): tree = DirectoryTree("~/projects") yield Container(tree) + tree.focus() app = TreeApp() diff --git a/src/textual/app.py b/src/textual/app.py index d7ec74e0e5..3b376fb9e4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -240,7 +240,7 @@ def __init_subclass__( title: Reactive[str] = Reactive("Textual") sub_title: Reactive[str] = Reactive("") - dark: Reactive[bool] = Reactive(False) + dark: Reactive[bool] = Reactive(True) @property def devtools_enabled(self) -> bool: @@ -899,11 +899,12 @@ def pop_screen(self) -> Screen: self.log.system(f"{self.screen} is active") return previous_screen - def set_focus(self, widget: Widget | None) -> None: + def set_focus(self, widget: Widget | None, scroll_visible: bool = False) -> None: """Focus (or unfocus) a widget. A focused widget will receive key events first. Args: - widget (Widget): [description] + widget (Widget): Widget to focus. + scroll_visible (bool, optional): Scroll widget in to view. """ if widget == self.focused: # Widget is already focused @@ -924,7 +925,8 @@ def set_focus(self, widget: Widget | None) -> None: # Change focus self.focused = widget # Send focus event - self.screen.scroll_to_widget(widget) + if scroll_visible: + self.screen.scroll_to_widget(widget) widget.post_message_no_wait(events.Focus(self)) widget.emit_no_wait(events.DescendantFocus(self)) diff --git a/src/textual/dom.py b/src/textual/dom.py index 9b828c0668..70bc649241 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -488,7 +488,8 @@ def rich_style(self) -> Style: """Get a Rich Style object for this DOMNode.""" _, _, background, color = self.colors style = ( - Style.from_color(color.rich_color, background.rich_color) + self.text_style + Style.from_color((background + color).rich_color, background.rich_color) + + self.text_style ) return style diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 01cecabcac..664546c79b 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -522,10 +522,12 @@ async def dispatch_key(self, event: events.Key) -> None: Args: event (events.Key): A key event. """ - key_method = getattr(self, f"key_{event.key_name}", None) + key_method = getattr(self, f"key_{event.key_name}", None) or getattr( + self, f"_key_{event.key_name}", None + ) if key_method is not None: - if await invoke(key_method, event): - event.prevent_default() + await invoke(key_method, event) + event.prevent_default() async def on_timer(self, event: events.Timer) -> None: event.prevent_default() diff --git a/src/textual/messages.py b/src/textual/messages.py index 55e42954da..cadeaeb211 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -3,6 +3,7 @@ import rich.repr +from .geometry import Region from ._types import CallbackType from .message import Message @@ -39,7 +40,7 @@ def can_replace(self, message: Message) -> bool: @rich.repr.auto -class InvokeLater(Message, verbose=True): +class InvokeLater(Message, verbose=True, bubble=False): def __init__(self, sender: MessagePump, callback: CallbackType) -> None: self.callback = callback super().__init__(sender) @@ -48,11 +49,12 @@ def __rich_repr__(self) -> rich.repr.Result: yield "callback", self.callback -# TODO: This should really be an Event @rich.repr.auto -class CursorMove(Message): - def __init__(self, sender: MessagePump, line: int) -> None: - self.line = line +class ScrollToRegion(Message, bubble=False): + """Ask the parent to scroll a given region in to view.""" + + def __init__(self, sender: MessagePump, region: Region) -> None: + self.region = region super().__init__(sender) diff --git a/src/textual/widget.py b/src/textual/widget.py index 138059bc38..65b847d237 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4,7 +4,6 @@ from fractions import Fraction from itertools import islice from operator import attrgetter -from types import GeneratorType from typing import TYPE_CHECKING, ClassVar, Collection, Iterable, NamedTuple import rich.repr @@ -611,6 +610,18 @@ def region(self) -> Region: except errors.NoWidget: return Region() + @property + def container_viewport(self) -> Region: + """The viewport region (parent window) + + Returns: + Region: The region that contains this widget. + """ + if self.parent is None: + return self.size.region + assert isinstance(self.parent, Widget) + return self.parent.region + @property def virtual_region(self) -> Region: """The widget region relative to it's container. Which may not be visible, @@ -1080,7 +1091,7 @@ def scroll_to_region( self.scroll_relative( delta.x or None, delta.y or None, - animate=animate, + animate=animate if (abs(delta_y) > 1 or delta_x) else False, duration=0.2, ) return delta @@ -1093,13 +1104,19 @@ def scroll_visible(self) -> None: def __init_subclass__( cls, - can_focus: bool = False, - can_focus_children: bool = True, + can_focus: bool | None = None, + can_focus_children: bool | None = None, inherit_css: bool = True, ) -> None: + base = cls.__mro__[0] super().__init_subclass__(inherit_css=inherit_css) - cls.can_focus = can_focus - cls.can_focus_children = can_focus_children + if issubclass(base, Widget): + cls.can_focus = base.can_focus if can_focus is None else can_focus + cls.can_focus_children = ( + base.can_focus_children + if can_focus_children is None + else can_focus_children + ) def __rich_repr__(self) -> rich.repr.Result: yield "id", self.id, None @@ -1529,49 +1546,52 @@ def _on_hide(self, event: events.Hide) -> None: if self.has_focus: self.app._reset_focus(self) - def key_home(self) -> bool: + def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None: + self.scroll_to_region(message.region, animate=True) + + def _key_home(self) -> bool: if self._allow_scroll: self.scroll_home() return True return False - def key_end(self) -> bool: + def _key_end(self) -> bool: if self._allow_scroll: self.scroll_end() return True return False - def key_left(self) -> bool: + def _key_left(self) -> bool: if self.allow_horizontal_scroll: self.scroll_left() return True return False - def key_right(self) -> bool: + def _key_right(self) -> bool: if self.allow_horizontal_scroll: self.scroll_right() return True return False - def key_down(self) -> bool: + def _key_down(self) -> bool: if self.allow_vertical_scroll: self.scroll_down() return True return False - def key_up(self) -> bool: + def _key_up(self) -> bool: if self.allow_vertical_scroll: self.scroll_up() return True return False - def key_pagedown(self) -> bool: + def _key_pagedown(self) -> bool: if self.allow_vertical_scroll: self.scroll_page_down() return True return False - def key_pageup(self) -> bool: + def _key_pageup(self) -> bool: if self.allow_vertical_scroll: self.scroll_page_up() return True diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 1eb8c3f8a8..d718888de5 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -11,9 +11,8 @@ from .. import events from ..message import Message -from ..reactive import Reactive from .._types import MessageTarget -from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID +from ._tree_control import TreeControl, TreeNode @dataclass @@ -44,21 +43,6 @@ def __init__( super().__init__(label, data, name=name, id=id, classes=classes) self.root.tree.guide_style = "black" - has_focus: Reactive[bool] = Reactive(False) - - def on_focus(self) -> None: - self.has_focus = True - - def on_blur(self) -> None: - self.has_focus = False - - async def watch_hover_node(self, hover_node: NodeID) -> None: - for node in self.nodes.values(): - node.tree.guide_style = ( - "bold not dim red" if node.id == hover_node else "black" - ) - self.refresh() - def render_node(self, node: TreeNode[DirEntry]) -> RenderableType: return self.render_tree_label( node, @@ -99,13 +83,17 @@ def render_tree_label( label.stylize("dim") if is_cursor and has_focus: - label.stylize("reverse") + cursor_style = self.get_component_styles("tree--cursor").rich_style + label.stylize(cursor_style) icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label icon_label.apply_meta(meta) return icon_label - async def on_mount(self, event: events.Mount) -> None: + def on_styles_updated(self) -> None: + self.render_tree_label.cache_clear() + + def on_mount(self) -> None: self.call_later(self.load_directory, self.root) async def load_directory(self, node: TreeNode[DirEntry]): @@ -114,21 +102,23 @@ async def load_directory(self, node: TreeNode[DirEntry]): list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name) ) for entry in directory: - await node.add(entry.name, DirEntry(entry.path, entry.is_dir())) + node.add(entry.name, DirEntry(entry.path, entry.is_dir())) node.loaded = True - await node.expand() + node.expand() self.refresh(layout=True) - async def on_tree_click(self, message: TreeClick[DirEntry]) -> None: + async def on_tree_control_node_selected( + self, message: TreeControl.NodeSelected[DirEntry] + ) -> None: dir_entry = message.node.data if not dir_entry.is_dir: await self.emit(FileClick(self, dir_entry.path)) else: if not message.node.loaded: await self.load_directory(message.node) - await message.node.expand() + message.node.expand() else: - await message.node.toggle() + message.node.toggle() if __name__ == "__main__": diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index b3200c0719..05f8f5b138 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -5,21 +5,24 @@ import rich.repr from rich.console import RenderableType +from rich.style import Style from rich.text import Text, TextType from rich.tree import Tree +from ..geometry import Region, Size from .. import events from ..reactive import Reactive from .._types import MessageTarget from ..widget import Widget from ..message import Message -from ..messages import CursorMove +from .. import messages NodeID = NewType("NodeID", int) NodeDataType = TypeVar("NodeDataType") +EventNodeDataType = TypeVar("EventNodeDataType") @rich.repr.auto @@ -141,16 +144,16 @@ def previous_sibling(self) -> TreeNode[NodeDataType] | None: sibling = node return None - async def expand(self, expanded: bool = True) -> None: + def expand(self, expanded: bool = True) -> None: self._expanded = expanded self._tree.expanded = expanded self._control.refresh(layout=True) - async def toggle(self) -> None: - await self.expand(not self._expanded) + def toggle(self) -> None: + self.expand(not self._expanded) - async def add(self, label: TextType, data: NodeDataType) -> None: - await self._control.add(self.id, label, data=data) + def add(self, label: TextType, data: NodeDataType) -> None: + self._control.add(self.id, label, data=data) self._control.refresh(layout=True) self._empty = False @@ -158,36 +161,56 @@ def __rich__(self) -> RenderableType: return self._control.render_node(self) -@rich.repr.auto -class TreeClick(Generic[NodeDataType], Message, bubble=True): - def __init__(self, sender: MessageTarget, node: TreeNode[NodeDataType]) -> None: - self.node = node - super().__init__(sender) - - def __rich_repr__(self) -> rich.repr.Result: - yield "node", self.node - - class TreeControl(Generic[NodeDataType], Widget, can_focus=True): DEFAULT_CSS = """ TreeControl { - background: $panel; - color: $text-panel; + background: $surface; + color: $text-surface; height: auto; width: 100%; } TreeControl > .tree--guides { - color: $secondary; + color: $success; + } + + TreeControl > .tree--guides-highlight { + color: $success; + text-style: uu; + + } + + TreeControl > .tree--guides-cursor { + color: $secondary; + text-style: bold; + } + + TreeControl > .tree--labels { + color: $text-panel; + } + + TreeControl > .tree--cursor { + background: $secondary; + color: $text-secondary; } """ COMPONENT_CLASSES: ClassVar[set[str]] = { "tree--guides", + "tree--guides-highlight", + "tree--guides-cursor", "tree--labels", + "tree--cursor", } + class NodeSelected(Generic[EventNodeDataType], Message, bubble=False): + def __init__( + self, sender: MessageTarget, node: TreeNode[EventNodeDataType] + ) -> None: + self.node = node + super().__init__(sender) + def __init__( self, label: TextType, @@ -202,6 +225,7 @@ def __init__( self.node_id = NodeID(0) self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {} self._tree = Tree(label) + self.root: TreeNode[NodeDataType] = TreeNode( None, self.node_id, self, self._tree, label, data ) @@ -215,22 +239,30 @@ def __init__( cursor_line: Reactive[int] = Reactive(0) show_cursor: Reactive[bool] = Reactive(False) - def watch_show_cursor(self, value: bool) -> None: - self.emit_no_wait(CursorMove(self, self.cursor_line)) - def watch_cursor_line(self, value: int) -> None: - if self.show_cursor: - self.emit_no_wait(CursorMove(self, value + self.gutter.top)) + line_region = Region(0, value, self.size.width, 1) + self.emit_no_wait(messages.ScrollToRegion(self, line_region)) + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + def get_size(tree: Tree) -> int: + return 1 + sum( + get_size(child) if child.expanded else 1 for child in tree.children + ) - async def add( + size = get_size(self._tree) + return size + + def add( self, node_id: NodeID, label: TextType, data: NodeDataType, ) -> None: + parent = self.nodes[node_id] self.node_id = NodeID(self.node_id + 1) child_tree = parent._tree.add(label) + child_tree.guide_style = self._guide_style child_node: TreeNode[NodeDataType] = TreeNode( parent, self.node_id, self, child_tree, label, data ) @@ -267,12 +299,29 @@ def find_cursor(self) -> int | None: return None def render(self) -> RenderableType: - self._tree.guide_style = self._component_styles["tree--guides"].node.rich_style + guide_style = self._guide_style + + def update_guide_style(tree: Tree) -> None: + tree.guide_style = guide_style + for child in tree.children: + if child.expanded: + update_guide_style(child) + + update_guide_style(self._tree) + if self.hover_node is not None: + hover = self.nodes.get(self.hover_node) + if hover is not None: + hover._tree.guide_style = self._highlight_guide_style + if self.cursor is not None and self.show_cursor: + cursor = self.nodes.get(self.cursor) + if cursor is not None: + cursor._tree.guide_style = self._cursor_guide_style return self._tree def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType: + label_style = self.get_component_styles("tree--labels").rich_style label = ( - Text(node.label, no_wrap=True, overflow="ellipsis") + Text(node.label, no_wrap=True, style=label_style, overflow="ellipsis") if isinstance(node.label, str) else node.label ) @@ -281,33 +330,85 @@ def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType: label.apply_meta({"@click": f"click_label({node.id})", "tree_node": node.id}) return label - async def action_click_label(self, node_id: NodeID) -> None: + def action_click_label(self, node_id: NodeID) -> None: node = self.nodes[node_id] self.cursor = node.id self.cursor_line = self.find_cursor() or 0 - self.show_cursor = False - await self.post_message(TreeClick(self, node)) + self.show_cursor = True + self.post_message_no_wait(self.NodeSelected(self, node)) + + def on_mount(self) -> None: + self._tree.guide_style = self._guide_style - async def on_mouse_move(self, event: events.MouseMove) -> None: + @property + def _guide_style(self) -> Style: + return self.get_component_styles("tree--guides").rich_style + + @property + def _highlight_guide_style(self) -> Style: + return self.get_component_styles("tree--guides-highlight").rich_style + + @property + def _cursor_guide_style(self) -> Style: + return self.get_component_styles("tree--guides-cursor").rich_style + + def on_mouse_move(self, event: events.MouseMove) -> None: self.hover_node = event.style.meta.get("tree_node") async def on_key(self, event: events.Key) -> None: await self.dispatch_key(event) - async def key_down(self, event: events.Key) -> None: + def key_down(self, event: events.Key) -> None: event.stop() - await self.cursor_down() + self.cursor_down() - async def key_up(self, event: events.Key) -> None: + def key_up(self, event: events.Key) -> None: event.stop() - await self.cursor_up() + self.cursor_up() + + def key_pagedown(self) -> None: + assert self.parent is not None + height = self.container_viewport.height + + cursor = self.cursor + cursor_line = self.cursor_line + for _ in range(height): + cursor_node = self.nodes[cursor] + next_node = cursor_node.next_node + if next_node is not None: + cursor_line += 1 + cursor = next_node.id + self.cursor = cursor + self.cursor_line = cursor_line + + def key_pageup(self) -> None: + assert self.parent is not None + height = self.container_viewport.height + cursor = self.cursor + cursor_line = self.cursor_line + for _ in range(height): + cursor_node = self.nodes[cursor] + previous_node = cursor_node.previous_node + if previous_node is not None: + cursor_line -= 1 + cursor = previous_node.id + self.cursor = cursor + self.cursor_line = cursor_line + + def key_home(self) -> None: + self.cursor_line = 0 + self.cursor = NodeID(0) + + def key_end(self) -> None: + self.cursor = self.nodes[NodeID(0)].children[-1].id + self.cursor_line = self.find_cursor() or 0 - async def key_enter(self, event: events.Key) -> None: + def key_enter(self, event: events.Key) -> None: cursor_node = self.nodes[self.cursor] event.stop() - await self.post_message(TreeClick(self, cursor_node)) + self.post_message_no_wait(self.NodeSelected(self, cursor_node)) - async def cursor_down(self) -> None: + def cursor_down(self) -> None: if not self.show_cursor: self.show_cursor = True return @@ -317,7 +418,7 @@ async def cursor_down(self) -> None: self.cursor_line += 1 self.cursor = next_node.id - async def cursor_up(self) -> None: + def cursor_up(self) -> None: if not self.show_cursor: self.show_cursor = True return