mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
tree fix
This commit is contained in:
@@ -5,16 +5,10 @@ from textual.widgets import DirectoryTree
|
||||
|
||||
|
||||
class TreeApp(App):
|
||||
DEFAULT_CSS = """
|
||||
Screen {
|
||||
overflow: auto;
|
||||
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
tree = DirectoryTree("~/projects")
|
||||
yield Container(tree)
|
||||
tree.focus()
|
||||
|
||||
|
||||
app = TreeApp()
|
||||
|
||||
@@ -240,7 +240,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
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:
|
||||
|
||||
@@ -488,7 +488,8 @@ class DOMNode(MessagePump):
|
||||
"""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
|
||||
|
||||
|
||||
@@ -522,9 +522,11 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
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):
|
||||
await invoke(key_method, event)
|
||||
event.prevent_default()
|
||||
|
||||
async def on_timer(self, event: events.Timer) -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import rich.repr
|
||||
|
||||
from .geometry import Region
|
||||
from ._types import CallbackType
|
||||
from .message import Message
|
||||
|
||||
@@ -39,7 +40,7 @@ class Layout(Message, verbose=True):
|
||||
|
||||
|
||||
@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,10 @@ class InvokeLater(Message, verbose=True):
|
||||
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):
|
||||
def __init__(self, sender: MessagePump, region: Region) -> None:
|
||||
self.region = region
|
||||
super().__init__(sender)
|
||||
|
||||
|
||||
|
||||
@@ -611,6 +611,18 @@ class Widget(DOMNode):
|
||||
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,
|
||||
@@ -1070,6 +1082,7 @@ class Widget(DOMNode):
|
||||
window = self.content_region.at_offset(self.scroll_offset)
|
||||
if spacing is not None:
|
||||
window = window.shrink(spacing)
|
||||
self.log(window=window, region=region)
|
||||
delta_x, delta_y = Region.get_scroll_to_visible(window, region)
|
||||
scroll_x, scroll_y = self.scroll_offset
|
||||
delta = Offset(
|
||||
@@ -1080,7 +1093,7 @@ class Widget(DOMNode):
|
||||
self.scroll_relative(
|
||||
delta.x or None,
|
||||
delta.y or None,
|
||||
animate=animate,
|
||||
animate=animate if abs(delta_y) > 1 else False,
|
||||
duration=0.2,
|
||||
)
|
||||
return delta
|
||||
@@ -1093,13 +1106,21 @@ class Widget(DOMNode):
|
||||
|
||||
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 +1550,52 @@ class Widget(DOMNode):
|
||||
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
|
||||
|
||||
@@ -44,21 +44,6 @@ class DirectoryTree(TreeControl[DirEntry]):
|
||||
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 +84,14 @@ class DirectoryTree(TreeControl[DirEntry]):
|
||||
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_mount(self) -> None:
|
||||
self.call_later(self.load_directory, self.root)
|
||||
|
||||
async def load_directory(self, node: TreeNode[DirEntry]):
|
||||
@@ -113,22 +99,23 @@ class DirectoryTree(TreeControl[DirEntry]):
|
||||
directory = sorted(
|
||||
list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name)
|
||||
)
|
||||
self.log(directory)
|
||||
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: TreeClick[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__":
|
||||
|
||||
@@ -8,12 +8,13 @@ from rich.console import RenderableType
|
||||
from rich.text import Text, TextType
|
||||
from rich.tree import Tree
|
||||
|
||||
from ..geometry import Region
|
||||
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)
|
||||
@@ -141,16 +142,16 @@ class TreeNode(Generic[NodeDataType]):
|
||||
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
|
||||
|
||||
@@ -178,16 +179,37 @@ class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
|
||||
}
|
||||
|
||||
TreeControl > .tree--guides {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
TreeControl > .tree--guides-highlight {
|
||||
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--labels",
|
||||
"tree--cursor",
|
||||
}
|
||||
|
||||
class NodeSelected(Message, bubble=False):
|
||||
def __init__(self, sender: MessageTarget, node: TreeNode[NodeDataType]) -> None:
|
||||
self.node = node
|
||||
super().__init__(sender)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: TextType,
|
||||
@@ -202,6 +224,7 @@ class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
|
||||
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
|
||||
)
|
||||
@@ -216,21 +239,43 @@ class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
|
||||
show_cursor: Reactive[bool] = Reactive(False)
|
||||
|
||||
def watch_show_cursor(self, value: bool) -> None:
|
||||
self.emit_no_wait(CursorMove(self, self.cursor_line))
|
||||
line_region = Region(0, self.cursor_line, self.size.width, 1)
|
||||
self.emit_no_wait(messages.ScrollToRegion(self, line_region))
|
||||
|
||||
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))
|
||||
|
||||
async def add(
|
||||
def watch_hover_node(self, previous_hover_node: NodeID, hover_node: NodeID) -> None:
|
||||
previous_hover = self.nodes.get(previous_hover_node)
|
||||
if previous_hover is not None:
|
||||
previous_hover._tree.guide_style = self._guide_style
|
||||
hover = self.nodes.get(hover_node)
|
||||
if hover is not None:
|
||||
hover._tree.guide_style = self._highlight_guide_style
|
||||
self.refresh()
|
||||
|
||||
def watch_cursor(self, previous_cursor_node: NodeID, cursor_node: NodeID) -> None:
|
||||
|
||||
previous_cursor = self.nodes.get(previous_cursor_node)
|
||||
if previous_cursor is not None:
|
||||
previous_cursor._tree.guide_style = self._guide_style
|
||||
cursor = self.nodes.get(cursor_node)
|
||||
if cursor is not None:
|
||||
cursor._tree.guide_style = self._highlight_guide_style
|
||||
self.refresh()
|
||||
|
||||
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 +312,12 @@ class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
|
||||
return None
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
self._tree.guide_style = self._component_styles["tree--guides"].node.rich_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 +326,77 @@ class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
|
||||
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))
|
||||
|
||||
async def on_mouse_move(self, event: events.MouseMove) -> None:
|
||||
def on_mount(self) -> None:
|
||||
self._guide_style = self.get_component_styles("tree--guides").rich_style
|
||||
self._highlight_guide_style = self.get_component_styles(
|
||||
"tree--guides-highlight"
|
||||
).rich_style
|
||||
self._tree.guide_style = self._guide_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()
|
||||
|
||||
async def key_enter(self, event: events.Key) -> None:
|
||||
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
|
||||
|
||||
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 +406,7 @@ class TreeControl(Generic[NodeDataType], Widget, can_focus=True):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user