From b794347ff11d6ac6b69134ac9a0aaf73bd6ff175 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 13 Nov 2022 16:45:55 +0000 Subject: [PATCH 01/33] new tree control --- src/textual/widgets/__init__.py | 4 +- src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_tree.py | 245 +++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/textual/widgets/_tree.py diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index c42b011a9..d5ed7644b 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -20,6 +20,7 @@ if typing.TYPE_CHECKING: from ._static import Static from ._input import Input from ._text_log import TextLog + from ._tree import Tree from ._tree_control import TreeControl from ._welcome import Welcome @@ -30,11 +31,12 @@ __all__ = [ "DirectoryTree", "Footer", "Header", + "Input", "Placeholder", "Pretty", "Static", - "Input", "TextLog", + "Tree", "TreeControl", "Welcome", ] diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 5ceb01835..389d1daf6 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -10,5 +10,6 @@ from ._pretty import Pretty as Pretty from ._static import Static as Static from ._input import Input as Input from ._text_log import TextLog as TextLog +from ._tree import Tree as Tree from ._tree_control import TreeControl as TreeControl from ._welcome import Welcome as Welcome diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py new file mode 100644 index 000000000..b306becf1 --- /dev/null +++ b/src/textual/widgets/_tree.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Generic, TypeVar + +import rich.repr +from rich.segment import Segment +from rich.style import Style +from rich.text import Text, TextType + + +from ..binding import Binding +from ..geometry import clamp, Region +from .._loop import loop_last +from .._cache import LRUCache +from ..reactive import reactive +from .._segment_tools import line_crop +from ..scroll_view import ScrollView + +from .. import events + +TreeDataType = TypeVar("TreeDataType") + + +@dataclass +class _TreeLine: + parents: list[TreeNode] + node: TreeNode + last: bool + + +@rich.repr.auto +class TreeNode(Generic[TreeDataType]): + def __init__( + self, + tree: Tree, + parent: TreeNode[TreeDataType] | None, + label: Text, + data: TreeDataType, + *, + expanded: bool = True, + ) -> None: + self._tree = tree + self._parent = parent + self.label = label + self.data: TreeDataType = data + self.expanded = expanded + self.children: list[TreeNode] = [] + + def __rich_repr__(self) -> rich.repr.Result: + yield self.label.plain + yield self.data + + @property + def last(self) -> bool: + if self._parent is None: + return True + return bool( + self._parent.children and self._parent.children[-1] == self, + ) + + def add( + self, label: TextType, data: TreeDataType, expanded: bool = True + ) -> TreeNode[TreeDataType]: + if isinstance(label, str): + text_label = Text.from_markup(label) + else: + text_label = label + node = TreeNode( + self._tree, + self, + text_label, + data, + expanded=expanded, + ) + self.children.append(node) + self._tree.invalidate() + return node + + +class Tree(Generic[TreeDataType], ScrollView, can_focus=True): + + BINDINGS = [ + Binding("up", "cursor_up", "Cursor Up"), + Binding("down", "cursor_down", "Cursor Down"), + ] + + DEFAULT_CSS = """ + Tree { + background: $surface; + color: $text; + } + Tree > .tree--guides { + color: $success; + } + Tree > .tree--cursor { + background: $secondary; + color: $text; + } + Tree > .tree--highlight { + + text-style: underline; + } + + """ + + show_root = reactive(True) + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "tree--guides", + "tree--cursor", + "tree--highlight", + } + + hover_line = reactive(-1, repaint=False) + cursor_line = reactive(0, repaint=False) + + def __init__( + self, + label: TextType, + data: TreeDataType, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + if isinstance(label, str): + text_label = Text.from_markup(label) + else: + text_label = label + + self.root: TreeNode[TreeDataType] = TreeNode( + self, None, text_label, data, expanded=True + ) + self._tree_lines_cached: list[_TreeLine] | None = None + + def validate_cursor_line(self, value: int) -> int: + return clamp(value, 0, len(self._tree_lines) - 1) + + def invalidate(self) -> None: + self._tree_lines_cached = None + + def _on_mouse_move(self, event: events.MouseMove): + meta = event.style.meta + + if meta and "line" in meta: + self.hover_line = meta["line"] + else: + self.hover_line = -1 + + def watch_hover_line(self, previous_hover_line: int, hover_line: int) -> None: + self.refresh_line(previous_hover_line) + self.refresh_line(hover_line) + + def watch_cursor_line(self, previous_cursor: int, cursor: int) -> None: + self.refresh_line(previous_cursor) + self.refresh_line(cursor) + + def refresh_line(self, line: int) -> None: + region = Region(0, line - self.scroll_offset.y, self.size.width, 1) + if not self.window_region.overlaps(region): + return + self.refresh(region) + + @property + def _tree_lines(self) -> list[_TreeLine]: + if self._tree_lines_cached is None: + self._build() + assert self._tree_lines_cached is not None + return self._tree_lines_cached + + def _build(self) -> None: + + lines: list[_TreeLine] = [] + add_line = lines.append + + root = self.root + + def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None: + + add_line(_TreeLine(path, node, last)) + + if node.expanded: + child_path = [*path, node] + for last, child in loop_last(node.children): + add_node(child_path, child, last=last) + + add_node([], root, True) + self._tree_lines_cached = lines + + def render_line(self, y: int) -> list[Segment]: + width, height = self.size + scroll_x, scroll_y = self.scroll_offset + y += scroll_y + style = self.rich_style + return self._render_line(y, scroll_x, scroll_x + width, style) + + def _render_line( + self, y: int, x1: int, x2: int, base_style: Style + ) -> list[Segment]: + tree_lines = self._tree_lines + width = self.size.width + + if y >= len(tree_lines): + return [Segment(" " * width, base_style)] + + style = base_style + + line = tree_lines[y] + + guide_style = base_style + self.get_component_rich_style("tree--guides") + guides = Text.assemble( + *[ + (" " if node.last else "│ ", guide_style) + for node in line.parents[1:] + ] + ) + if line.parents: + if line.last: + guides.append("└── ", style=guide_style) + else: + guides.append("├── ", style=guide_style) + label = line.node.label.copy() + if self.hover_line == y: + label.stylize(self.get_component_rich_style("tree--highlight")) + if self.cursor_line == y and self.has_focus: + label.stylize(self.get_component_rich_style("tree--cursor")) + label.stylize(Style(meta={"line": y})) + + guides.append(label) + + segments = list(guides.render(self.app.console)) + segments = Segment.adjust_line_length(segments, width, style=style) + segments = list(Segment.apply_style(segments, style)) + segments = line_crop(segments, x1, x2, width) + simplified_segments = list(Segment.simplify(segments)) + + return simplified_segments + + def action_cursor_up(self) -> None: + self.cursor_line -= 1 + + def action_cursor_down(self) -> None: + self.cursor_line += 1 From 89eb42490e4a76ff43cdb91c80b8ab4d27dcce96 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Nov 2022 11:35:27 +0000 Subject: [PATCH 02/33] recursive highlight for select and hover -a --- src/textual/widgets/_tree.py | 231 +++++++++++++++++++++++++++++------ 1 file changed, 192 insertions(+), 39 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index b306becf1..876ab9d3a 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import ClassVar, Generic, TypeVar +from typing import ClassVar, Generic, NewType, TypeVar import rich.repr from rich.segment import Segment @@ -19,15 +19,19 @@ from ..scroll_view import ScrollView from .. import events +NodeID = NewType("NodeID", int) TreeDataType = TypeVar("TreeDataType") @dataclass class _TreeLine: - parents: list[TreeNode] - node: TreeNode + path: list[TreeNode] last: bool + @property + def node(self) -> TreeNode: + return self.path[-1] + @rich.repr.auto class TreeNode(Generic[TreeDataType]): @@ -35,6 +39,7 @@ class TreeNode(Generic[TreeDataType]): self, tree: Tree, parent: TreeNode[TreeDataType] | None, + id: NodeID, label: Text, data: TreeDataType, *, @@ -42,17 +47,26 @@ class TreeNode(Generic[TreeDataType]): ) -> None: self._tree = tree self._parent = parent + self.id = id self.label = label self.data: TreeDataType = data self.expanded = expanded self.children: list[TreeNode] = [] + self._hover = False + self._selected = False + def __rich_repr__(self) -> rich.repr.Result: yield self.label.plain yield self.data @property def last(self) -> bool: + """Check if this is the last child. + + Returns: + bool: True if this is the last child, otherwise False. + """ if self._parent is None: return True return bool( @@ -66,13 +80,8 @@ class TreeNode(Generic[TreeDataType]): text_label = Text.from_markup(label) else: text_label = label - node = TreeNode( - self._tree, - self, - text_label, - data, - expanded=expanded, - ) + node = self._tree._add_node(self, text_label, data) + node.expanded = expanded self.children.append(node) self._tree.invalidate() return node @@ -89,16 +98,30 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): Tree { background: $surface; color: $text; + } + Tree > .tree--label { + } Tree > .tree--guides { color: $success; } + + Tree > .tree--guides-hover { + color: $error; + text-style: uu; + } + + Tree > .tree--guides-selected { + color: $warning; + text-style: bold; + } + Tree > .tree--cursor { background: $secondary; color: $text; + text-style: bold; } - Tree > .tree--highlight { - + Tree > .tree--highlight { text-style: underline; } @@ -107,13 +130,37 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): show_root = reactive(True) COMPONENT_CLASSES: ClassVar[set[str]] = { + "tree--label", "tree--guides", + "tree--guides-hover", + "tree--guides-selected", "tree--cursor", "tree--highlight", } - hover_line = reactive(-1, repaint=False) - cursor_line = reactive(0, repaint=False) + hover_line: reactive[int] = reactive(-1, repaint=False) + cursor_line: reactive[int] = reactive(0, repaint=False) + + LINES: dict[str, tuple[str, str, str, str]] = { + "default": ( + " ", + "│ ", + "└── ", + "├── ", + ), + "bold": ( + " ", + "┃ ", + "┗━━ ", + "┣━━ ", + ), + "double": ( + " ", + "║ ", + "╚══ ", + "╠══ ", + ), + } def __init__( self, @@ -130,32 +177,88 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): else: text_label = label - self.root: TreeNode[TreeDataType] = TreeNode( - self, None, text_label, data, expanded=True - ) + self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} + self._current_id = 0 + self.root = self._add_node(None, text_label, data) + self.root.expanded = True self._tree_lines_cached: list[_TreeLine] | None = None + def _add_node( + self, parent: TreeNode[TreeDataType] | None, label: Text, data: TreeDataType + ) -> TreeNode[TreeDataType]: + node = TreeNode(self, parent, self._new_id(), label, data) + self._nodes[node.id] = node + return node + + def clear(self) -> None: + """Clear all nodes under root.""" + self._tree_lines_cached = None + self._current_id = 0 + root_label = self.root.label + root_data = self.root.data + self.root = TreeNode( + self, + None, + self._new_id(), + root_label, + root_data, + expanded=True, + ) + self.refresh() + def validate_cursor_line(self, value: int) -> int: return clamp(value, 0, len(self._tree_lines) - 1) def invalidate(self) -> None: self._tree_lines_cached = None + self.refresh() def _on_mouse_move(self, event: events.MouseMove): meta = event.style.meta - if meta and "line" in meta: self.hover_line = meta["line"] else: self.hover_line = -1 - def watch_hover_line(self, previous_hover_line: int, hover_line: int) -> None: - self.refresh_line(previous_hover_line) - self.refresh_line(hover_line) + def _new_id(self) -> NodeID: + """Create a new node ID. - def watch_cursor_line(self, previous_cursor: int, cursor: int) -> None: - self.refresh_line(previous_cursor) - self.refresh_line(cursor) + Returns: + NodeID: A unique node ID. + """ + id = self._current_id + self._current_id += 1 + return NodeID(id) + + def _get_node(self, line: int) -> TreeNode[TreeDataType] | None: + try: + tree_line = self._tree_lines[line] + except IndexError: + return None + else: + return tree_line.node + + def watch_hover_line(self, previous_hover_line: int, hover_line: int) -> None: + previous_node = self._get_node(previous_hover_line) + if previous_node is not None: + self._refresh_node(previous_node) + previous_node._hover = False + + node = self._get_node(hover_line) + if node is not None: + self._refresh_node(node) + node._hover = True + + def watch_cursor_line(self, previous_line: int, line: int) -> None: + previous_node = self._get_node(previous_line) + if previous_node is not None: + self._refresh_node(previous_node) + previous_node._selected = False + + node = self._get_node(line) + if node is not None: + self._refresh_node(node) + node._selected = True def refresh_line(self, line: int) -> None: region = Region(0, line - self.scroll_offset.y, self.size.width, 1) @@ -163,6 +266,24 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): return self.refresh(region) + def _refresh_node_line(self, line: int) -> None: + node = self._get_node(line) + if node is not None: + self._refresh_node(node) + + def _refresh_node(self, node: TreeNode[TreeDataType]) -> None: + """Refresh a node and all its children. + + Args: + node (TreeNode[TreeDataType]): A tree node. + """ + scroll_y = self.scroll_offset.y + height = self.size.height + visible_lines = self._tree_lines[scroll_y : scroll_y + height] + for line_no, line in enumerate(visible_lines, scroll_y): + if node in line.path: + self.refresh_line(line_no) + @property def _tree_lines(self) -> list[_TreeLine]: if self._tree_lines_cached is None: @@ -178,11 +299,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): root = self.root def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None: - - add_line(_TreeLine(path, node, last)) - + child_path = [*path, node] + add_line(_TreeLine(child_path, last)) if node.expanded: - child_path = [*path, node] for last, child in loop_last(node.children): add_node(child_path, child, last=last) @@ -209,24 +328,58 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): line = tree_lines[y] - guide_style = base_style + self.get_component_rich_style("tree--guides") - guides = Text.assemble( - *[ - (" " if node.last else "│ ", guide_style) - for node in line.parents[1:] - ] + base_guide_style = base_style + self.get_component_rich_style("tree--guides") + + guide_hover_style = base_guide_style + self.get_component_rich_style( + "tree--guides-hover" ) - if line.parents: + guide_selected_style = base_guide_style + self.get_component_rich_style( + "tree--guides-selected" + ) + + hover = self.root._hover + selected = self.root._selected and self.has_focus + + guides = Text() + guides_append = guides.append + guide_style = base_guide_style + + def get_guides(style: Style) -> tuple[str, str, str, str]: + lines = self.LINES["default"] + if style.bold: + lines = self.LINES["bold"] + elif style.underline2: + lines = self.LINES["double"] + return lines + + for node in line.path[1:]: + + guide_style = base_guide_style + if hover: + guide_style = guide_hover_style + if selected: + guide_style = guide_selected_style + + space, vertical, _, _ = get_guides(guide_style) + guide = space if node.last else vertical + if node != line.path[-1]: + guides_append(guide, style=guide_style) + hover = hover or node._hover + selected = (selected or node._selected) and self.has_focus + + if len(line.path) > 1: + _, _, terminator, cross = get_guides(guide_style) if line.last: - guides.append("└── ", style=guide_style) + guides.append(terminator, style=guide_style) else: - guides.append("├── ", style=guide_style) - label = line.node.label.copy() + guides.append(cross, style=guide_style) + label = line.path[-1].label.copy() + label.stylize(self.get_component_rich_style("tree--label")) if self.hover_line == y: label.stylize(self.get_component_rich_style("tree--highlight")) if self.cursor_line == y and self.has_focus: label.stylize(self.get_component_rich_style("tree--cursor")) - label.stylize(Style(meta={"line": y})) + label.stylize(Style(meta={"node": line.node.id, "line": y})) guides.append(label) From 32ea8eb1ef1dd3dc7a42b13897710c86e40d0d20 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Nov 2022 18:13:23 +0000 Subject: [PATCH 03/33] focus and scroll --- src/textual/widget.py | 44 ++++++++++++++------- src/textual/widgets/_tree.py | 76 ++++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 45 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 9029eedcb..765d2b6af 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,14 +1,15 @@ from __future__ import annotations -from asyncio import Lock, wait, create_task, Event as AsyncEvent +from asyncio import Event as AsyncEvent +from asyncio import Lock, create_task, wait from fractions import Fraction from itertools import islice from operator import attrgetter from typing import ( - Generator, TYPE_CHECKING, ClassVar, Collection, + Generator, Iterable, NamedTuple, Sequence, @@ -31,7 +32,7 @@ from rich.style import Style from rich.text import Text from . import errors, events, messages -from ._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction +from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING @@ -39,7 +40,8 @@ from ._layout import Layout from ._segment_tools import align_lines from ._styles_cache import StylesCache from ._types import Lines -from .binding import NoBinding +from .await_remove import AwaitRemove +from .binding import Binding from .box_model import BoxModel, get_box_model from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen @@ -49,7 +51,6 @@ from .message import Message from .messages import CallbackType from .reactive import Reactive from .render import measure -from .await_remove import AwaitRemove if TYPE_CHECKING: from .app import App, ComposeResult @@ -166,6 +167,11 @@ class Widget(DOMNode): """ + BINDINGS = [ + Binding("up", "scroll_up", "Scroll Up", show=False), + Binding("down", "scroll_down", "Scroll Down", show=False), + ] + DEFAULT_CSS = """ Widget{ scrollbar-background: $panel-darken-1; @@ -2232,17 +2238,17 @@ class Widget(DOMNode): return True return False - def _key_down(self) -> bool: - if self.allow_vertical_scroll: - self.scroll_down() - return True - return False + # def _key_down(self) -> bool: + # if self.allow_vertical_scroll: + # self.scroll_down() + # return True + # return False - def _key_up(self) -> bool: - if self.allow_vertical_scroll: - self.scroll_up() - return True - return False + # def _key_up(self) -> bool: + # if self.allow_vertical_scroll: + # self.scroll_up() + # return True + # return False def _key_pagedown(self) -> bool: if self.allow_vertical_scroll: @@ -2255,3 +2261,11 @@ class Widget(DOMNode): self.scroll_page_up() return True return False + + def action_scroll_up(self) -> None: + if self.allow_vertical_scroll: + self.scroll_up() + + def action_scroll_down(self) -> None: + if self.allow_vertical_scroll: + self.scroll_down() diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 876ab9d3a..cf56e1d36 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from operator import attrgetter from typing import ClassVar, Generic, NewType, TypeVar import rich.repr @@ -10,11 +11,11 @@ from rich.text import Text, TextType from ..binding import Binding -from ..geometry import clamp, Region +from ..geometry import clamp, Region, Size from .._loop import loop_last from .._cache import LRUCache from ..reactive import reactive -from .._segment_tools import line_crop +from .._segment_tools import line_crop, line_pad from ..scroll_view import ScrollView from .. import events @@ -32,6 +33,10 @@ class _TreeLine: def node(self) -> TreeNode: return self.path[-1] + @property + def line_width(self) -> int: + return (len(self.path) * 4) + self.path[-1].label.cell_len - 4 + @rich.repr.auto class TreeNode(Generic[TreeDataType]): @@ -96,7 +101,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): DEFAULT_CSS = """ Tree { - background: $surface; + background: $panel; color: $text; } Tree > .tree--label { @@ -106,21 +111,23 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): color: $success; } - Tree > .tree--guides-hover { - color: $error; + + Tree > .tree--guides-hover { + color: $success; text-style: uu; } Tree > .tree--guides-selected { color: $warning; text-style: bold; - } + } Tree > .tree--cursor { background: $secondary; color: $text; text-style: bold; } + Tree > .tree--highlight { text-style: underline; } @@ -258,12 +265,14 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): node = self._get_node(line) if node is not None: self._refresh_node(node) + self.scroll_to_line(line) node._selected = True + def scroll_to_line(self, line: int) -> None: + self.scroll_to_region(Region(0, line, self.size.width, 1)) + def refresh_line(self, line: int) -> None: region = Region(0, line - self.scroll_offset.y, self.size.width, 1) - if not self.window_region.overlaps(region): - return self.refresh(region) def _refresh_node_line(self, line: int) -> None: @@ -308,6 +317,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): add_node([], root, True) self._tree_lines_cached = lines + width = max(lines, key=attrgetter("line_width")).line_width + self.virtual_size = Size(width, len(lines)) + def render_line(self, y: int) -> list[Segment]: width, height = self.size scroll_x, scroll_y = self.scroll_offset @@ -324,27 +336,27 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): if y >= len(tree_lines): return [Segment(" " * width, base_style)] - style = base_style - line = tree_lines[y] - base_guide_style = base_style + self.get_component_rich_style("tree--guides") - - guide_hover_style = base_guide_style + self.get_component_rich_style( - "tree--guides-hover" - ) - guide_selected_style = base_guide_style + self.get_component_rich_style( - "tree--guides-selected" - ) + base_guide_style = self.get_component_rich_style("tree--guides") + guide_hover_style = self.get_component_rich_style("tree--guides-hover") + guide_selected_style = self.get_component_rich_style("tree--guides-selected") hover = self.root._hover selected = self.root._selected and self.has_focus - guides = Text() + guides = Text(style=base_style) guides_append = guides.append - guide_style = base_guide_style def get_guides(style: Style) -> tuple[str, str, str, str]: + """Get the guide strings for a given style. + + Args: + style (Style): A Style object. + + Returns: + tuple[str, str, str, str]: Strings for space, vertical, terminator and cross. + """ lines = self.LINES["default"] if style.bold: lines = self.LINES["bold"] @@ -352,13 +364,12 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): lines = self.LINES["double"] return lines + guide_style = base_guide_style for node in line.path[1:]: - - guide_style = base_guide_style - if hover: - guide_style = guide_hover_style if selected: guide_style = guide_selected_style + if hover: + guide_style = guide_hover_style space, vertical, _, _ = get_guides(guide_style) guide = space if node.last else vertical @@ -384,15 +395,20 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): guides.append(label) segments = list(guides.render(self.app.console)) - segments = Segment.adjust_line_length(segments, width, style=style) - segments = list(Segment.apply_style(segments, style)) - segments = line_crop(segments, x1, x2, width) - simplified_segments = list(Segment.simplify(segments)) - return simplified_segments + segments = line_pad(segments, 0, width - guides.cell_len, base_style) + segments = line_crop(segments, x1, x2, width) + + return segments + + def _on_size(self) -> None: + self.invalidate() def action_cursor_up(self) -> None: - self.cursor_line -= 1 + if self.cursor_line == -1: + self.cursor_line = len(self._tree_lines) + else: + self.cursor_line -= 1 def action_cursor_down(self) -> None: self.cursor_line += 1 From 4473bc6ce6e6c1f631c48a794f813ccd99a50e3d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Nov 2022 18:26:44 +0000 Subject: [PATCH 04/33] added partial rich style --- src/textual/css/styles.py | 14 ++++++++++++++ src/textual/widget.py | 19 ++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 17e5963a8..844cffa88 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -551,6 +551,20 @@ class StylesBase(ABC): self._align_height(height, parent_height), ) + @property + def partial_rich_style(self) -> Style: + """Get the style properties associated with this node only (not including parents in the DOM). + + Returns: + Style: Rich Style object + """ + style = Style( + color=self.color.rich_color if self.color is not None else None, + bgcolor=self.color.rich_color if self.color is not None else None, + ) + style += self.text_style + return style + @rich.repr.auto @dataclass diff --git a/src/textual/widget.py b/src/textual/widget.py index 765d2b6af..7de51e2bc 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -240,7 +240,7 @@ class Widget(DOMNode): self._arrangement_cache_key: tuple[int, Size] = (-1, Size()) self._styles_cache = StylesCache() - self._rich_style_cache: dict[str, Style] = {} + self._rich_style_cache: dict[str, tuple[Style, Style]] = {} self._stabilized_scrollbar_size: Size | None = None self._lock = Lock() @@ -340,7 +340,7 @@ class Widget(DOMNode): def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) - def get_component_rich_style(self, name: str) -> Style: + def get_component_rich_style(self, name: str, partial: bool = False) -> Style: """Get a *Rich* style for a component. Args: @@ -349,11 +349,16 @@ class Widget(DOMNode): Returns: Style: A Rich style object. """ - style = self._rich_style_cache.get(name) - if style is None: - style = self.get_component_styles(name).rich_style - self._rich_style_cache[name] = style - return style + + if name not in self._rich_style_cache: + component_styles = self.get_component_styles(name) + style = component_styles.rich_style + node_style = component_styles.partial_rich_style + self._rich_style_cache[name] = (style, node_style) + + style, node_style = self._rich_style_cache[name] + + return node_style if partial else style def _arrange(self, size: Size) -> DockArrangeResult: """Arrange children. From 551a7bb7f069dc37e8b9080b67d6ba309f670539 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 12:05:57 +0000 Subject: [PATCH 05/33] fix for styling issues --- src/textual/css/styles.py | 6 +++-- src/textual/widget.py | 10 ++++---- src/textual/widgets/_tree.py | 45 +++++++++++++++++++++++++----------- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 844cffa88..32cd03b4c 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -559,8 +559,10 @@ class StylesBase(ABC): Style: Rich Style object """ style = Style( - color=self.color.rich_color if self.color is not None else None, - bgcolor=self.color.rich_color if self.color is not None else None, + color=(self.color.rich_color if self.has_rule("color") else None), + bgcolor=( + self.background.rich_color if self.has_rule("background") else None + ), ) style += self.text_style return style diff --git a/src/textual/widget.py b/src/textual/widget.py index 7de51e2bc..fad5ed4d8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -340,7 +340,7 @@ class Widget(DOMNode): def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) - def get_component_rich_style(self, name: str, partial: bool = False) -> Style: + def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style: """Get a *Rich* style for a component. Args: @@ -353,12 +353,12 @@ class Widget(DOMNode): if name not in self._rich_style_cache: component_styles = self.get_component_styles(name) style = component_styles.rich_style - node_style = component_styles.partial_rich_style - self._rich_style_cache[name] = (style, node_style) + partial_style = component_styles.partial_rich_style + self._rich_style_cache[name] = (style, partial_style) - style, node_style = self._rich_style_cache[name] + style, partial_style = self._rich_style_cache[name] - return node_style if partial else style + return partial_style if partial else style def _arrange(self, size: Size) -> DockArrangeResult: """Arrange children. diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index cf56e1d36..05668ac07 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -114,7 +114,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): Tree > .tree--guides-hover { color: $success; - text-style: uu; + text-style: bold; } Tree > .tree--guides-selected { @@ -132,6 +132,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): text-style: underline; } + Tree > .tree--highlight-line { + background: $boost; + } + """ show_root = reactive(True) @@ -143,6 +147,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): "tree--guides-selected", "tree--cursor", "tree--highlight", + "tree--highlight-line", } hover_line: reactive[int] = reactive(-1, repaint=False) @@ -338,16 +343,17 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): line = tree_lines[y] - base_guide_style = self.get_component_rich_style("tree--guides") - guide_hover_style = self.get_component_rich_style("tree--guides-hover") - guide_selected_style = self.get_component_rich_style("tree--guides-selected") + base_guide_style = self.get_component_rich_style("tree--guides", partial=True) + guide_hover_style = base_guide_style + self.get_component_rich_style( + "tree--guides-hover", partial=True + ) + guide_selected_style = base_guide_style + self.get_component_rich_style( + "tree--guides-selected", partial=True + ) hover = self.root._hover selected = self.root._selected and self.has_focus - guides = Text(style=base_style) - guides_append = guides.append - def get_guides(style: Style) -> tuple[str, str, str, str]: """Get the guide strings for a given style. @@ -364,12 +370,22 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): lines = self.LINES["double"] return lines + is_hover = self.hover_line >= 0 and any(node._hover for node in line.path) + + if is_hover: + line_style = self.get_component_rich_style("tree--highlight-line") + else: + line_style = base_style + + guides = Text(style=line_style) + guides_append = guides.append + guide_style = base_guide_style for node in line.path[1:]: - if selected: - guide_style = guide_selected_style if hover: guide_style = guide_hover_style + if selected: + guide_style = guide_selected_style space, vertical, _, _ = get_guides(guide_style) guide = space if node.last else vertical @@ -385,18 +401,19 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): else: guides.append(cross, style=guide_style) label = line.path[-1].label.copy() - label.stylize(self.get_component_rich_style("tree--label")) + label.stylize(self.get_component_rich_style("tree--label", partial=True)) if self.hover_line == y: - label.stylize(self.get_component_rich_style("tree--highlight")) + label.stylize( + self.get_component_rich_style("tree--highlight", partial=True) + ) if self.cursor_line == y and self.has_focus: - label.stylize(self.get_component_rich_style("tree--cursor")) + label.stylize(self.get_component_rich_style("tree--cursor", partial=False)) label.stylize(Style(meta={"node": line.node.id, "line": y})) guides.append(label) segments = list(guides.render(self.app.console)) - - segments = line_pad(segments, 0, width - guides.cell_len, base_style) + segments = line_pad(segments, 0, width - guides.cell_len, line_style) segments = line_crop(segments, x1, x2, width) return segments From e84f9b1273b9710d6016da08bad028e166d1dd62 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 13:23:29 +0000 Subject: [PATCH 06/33] ws --- src/textual/widgets/_tree.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 05668ac07..bacc90c7d 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -111,7 +111,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): color: $success; } - Tree > .tree--guides-hover { color: $success; text-style: bold; From aab0dbfc4ab08a016bcf68ee1aecde67f152db7e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 15:06:02 +0000 Subject: [PATCH 07/33] Added cache and lru grow method --- src/textual/_cache.py | 8 ++++++++ src/textual/widgets/_tree.py | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index 2f9bdd49d..8ee05bf8a 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -59,6 +59,14 @@ class LRUCache(Generic[CacheKey, CacheValue]): def __len__(self) -> int: return len(self._cache) + def grow(self, maxsize: int) -> None: + """Grow the maximum size to at least `maxsize` elements. + + Args: + maxsize (int): New maximum size. + """ + self.maxsize = max(self.maxsize, maxsize) + def clear(self) -> None: """Clear the cache.""" with self._lock: diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index bacc90c7d..b6239fe38 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -16,6 +16,7 @@ from .._loop import loop_last from .._cache import LRUCache from ..reactive import reactive from .._segment_tools import line_crop, line_pad +from .._typing import TypeAlias from ..scroll_view import ScrollView from .. import events @@ -23,6 +24,8 @@ from .. import events NodeID = NewType("NodeID", int) TreeDataType = TypeVar("TreeDataType") +LineCacheKey: TypeAlias = tuple[int | tuple[int, ...], ...] + @dataclass class _TreeLine: @@ -188,10 +191,12 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): else: text_label = label + self._updates = 0 self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._current_id = 0 self.root = self._add_node(None, text_label, data) self.root.expanded = True + self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None def _add_node( @@ -199,6 +204,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): ) -> TreeNode[TreeDataType]: node = TreeNode(self, parent, self._new_id(), label, data) self._nodes[node.id] = node + self._updates += 1 return node def clear(self) -> None: @@ -215,6 +221,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): root_data, expanded=True, ) + self._updates += 1 self.refresh() def validate_cursor_line(self, value: int) -> int: @@ -222,6 +229,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def invalidate(self) -> None: self._tree_lines_cached = None + self._updates += 1 self.refresh() def _on_mouse_move(self, event: events.MouseMove): @@ -342,6 +350,21 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): line = tree_lines[y] + is_hover = self.hover_line >= 0 and any(node._hover for node in line.path) + + cache_key = ( + y, + width, + self._updates, + y == self.hover_line, + y == self.cursor_line, + self.has_focus, + tuple(node._hover for node in line.path), + tuple(node._selected for node in line.path), + ) + if cache_key in self._line_cache: + return self._line_cache[cache_key] + base_guide_style = self.get_component_rich_style("tree--guides", partial=True) guide_hover_style = base_guide_style + self.get_component_rich_style( "tree--guides-hover", partial=True @@ -369,8 +392,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): lines = self.LINES["double"] return lines - is_hover = self.hover_line >= 0 and any(node._hover for node in line.path) - if is_hover: line_style = self.get_component_rich_style("tree--highlight-line") else: @@ -415,10 +436,13 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): segments = line_pad(segments, 0, width - guides.cell_len, line_style) segments = line_crop(segments, x1, x2, width) + print(y, cache_key) + self._line_cache[cache_key] = segments return segments - def _on_size(self) -> None: + def _on_resize(self) -> None: self.invalidate() + self._line_cache.grow(self.size.height * 2) def action_cursor_up(self) -> None: if self.cursor_line == -1: @@ -428,3 +452,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def action_cursor_down(self) -> None: self.cursor_line += 1 + + def _on_click(self, event: events.Click) -> None: + meta = event.style.meta + if "line" in meta: + self.cursor_line = meta["line"] From b15cdc61acc76b267f1069c0875cb4d45ffdc7c1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 15:33:28 +0000 Subject: [PATCH 08/33] added guide depth --- src/textual/widgets/_tree.py | 46 +++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index b6239fe38..86b8a582d 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -36,9 +36,8 @@ class _TreeLine: def node(self) -> TreeNode: return self.path[-1] - @property - def line_width(self) -> int: - return (len(self.path) * 4) + self.path[-1].label.cell_len - 4 + def get_line_width(self, guide_depth: int) -> int: + return (len(self.path)) + self.path[-1].label.cell_len - guide_depth @rich.repr.auto @@ -154,25 +153,26 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): hover_line: reactive[int] = reactive(-1, repaint=False) cursor_line: reactive[int] = reactive(0, repaint=False) + guide_depth: reactive[int] = reactive(3, repaint=False, init=False) LINES: dict[str, tuple[str, str, str, str]] = { "default": ( - " ", - "│ ", - "└── ", - "├── ", + " ", + "│ ", + "└─", + "├─", ), "bold": ( - " ", - "┃ ", - "┗━━ ", - "┣━━ ", + " ", + "┃ ", + "┗━", + "┣━", ), "double": ( - " ", - "║ ", - "╚══ ", - "╠══ ", + " ", + "║ ", + "╚═", + "╠═", ), } @@ -227,6 +227,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def validate_cursor_line(self, value: int) -> int: return clamp(value, 0, len(self._tree_lines) - 1) + def validate_guide_depth(self, value: int) -> int: + return clamp(value, 1, 10) + def invalidate(self) -> None: self._tree_lines_cached = None self._updates += 1 @@ -280,6 +283,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.scroll_to_line(line) node._selected = True + def watch_guide_depth(self, guide_depth: int) -> None: + self.invalidate() + def scroll_to_line(self, line: int) -> None: self.scroll_to_region(Region(0, line, self.size.width, 1)) @@ -329,7 +335,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): add_node([], root, True) self._tree_lines_cached = lines - width = max(lines, key=attrgetter("line_width")).line_width + guide_depth = self.guide_depth + width = max([line.get_line_width(guide_depth) for line in lines]) + self.virtual_size = Size(width, len(lines)) def render_line(self, y: int) -> list[Segment]: @@ -390,6 +398,12 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): lines = self.LINES["bold"] elif style.underline2: lines = self.LINES["double"] + + guide_depth = max(0, self.guide_depth - 2) + lines = tuple( + f"{vertical}{horizontal * guide_depth} " + for vertical, horizontal in lines + ) return lines if is_hover: From 5c873a0d4498b27be39f4b8a3939c8dcc6a64777 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 15:35:12 +0000 Subject: [PATCH 09/33] tweak guide depth defaults --- src/textual/widgets/_tree.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 86b8a582d..7cf514a5f 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -153,7 +153,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): hover_line: reactive[int] = reactive(-1, repaint=False) cursor_line: reactive[int] = reactive(0, repaint=False) - guide_depth: reactive[int] = reactive(3, repaint=False, init=False) + guide_depth: reactive[int] = reactive(4, repaint=False, init=False) LINES: dict[str, tuple[str, str, str, str]] = { "default": ( @@ -228,7 +228,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): return clamp(value, 0, len(self._tree_lines) - 1) def validate_guide_depth(self, value: int) -> int: - return clamp(value, 1, 10) + return clamp(value, 2, 10) def invalidate(self) -> None: self._tree_lines_cached = None From 514a3c9f90fb69306bced8638aaaa84287f08ac2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 15:53:23 +0000 Subject: [PATCH 10/33] remove debug --- src/textual/widgets/_tree.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 7cf514a5f..23d219daf 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -450,7 +450,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): segments = line_pad(segments, 0, width - guides.cell_len, line_style) segments = line_crop(segments, x1, x2, width) - print(y, cache_key) self._line_cache[cache_key] = segments return segments From 22f37871d96608e5fb7f273ad2106605f3658f4d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Nov 2022 14:44:04 +0000 Subject: [PATCH 11/33] node selected --- src/textual/widgets/_tree.py | 42 ++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 23d219daf..7ce6b957b 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -14,8 +14,10 @@ from ..binding import Binding from ..geometry import clamp, Region, Size from .._loop import loop_last from .._cache import LRUCache +from ..message import Message from ..reactive import reactive from .._segment_tools import line_crop, line_pad +from .._types import MessageTarget from .._typing import TypeAlias from ..scroll_view import ScrollView @@ -23,6 +25,7 @@ from .. import events NodeID = NewType("NodeID", int) TreeDataType = TypeVar("TreeDataType") +EventTreeDataType = TypeVar("EventTreeDataType") LineCacheKey: TypeAlias = tuple[int | tuple[int, ...], ...] @@ -83,6 +86,16 @@ class TreeNode(Generic[TreeDataType]): def add( self, label: TextType, data: TreeDataType, expanded: bool = True ) -> TreeNode[TreeDataType]: + """Add a node to the sub-tree. + + Args: + label (TextType): The new node's label. + data (TreeDataType): Data associated with the new node. + expanded (bool, optional): Node should be expanded. Defaults to True. + + Returns: + TreeNode[TreeDataType]: A new Tree node + """ if isinstance(label, str): text_label = Text.from_markup(label) else: @@ -97,8 +110,9 @@ class TreeNode(Generic[TreeDataType]): class Tree(Generic[TreeDataType], ScrollView, can_focus=True): BINDINGS = [ - Binding("up", "cursor_up", "Cursor Up"), - Binding("down", "cursor_down", "Cursor Down"), + Binding("up", "cursor_up", "Cursor Up", show=False), + Binding("down", "cursor_down", "Cursor Down", show=False), + Binding("enter", "select_cursor", "Select", show=False), ] DEFAULT_CSS = """ @@ -176,6 +190,13 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): ), } + class NodeSelected(Generic[EventTreeDataType], Message, bubble=True): + def __init__( + self, sender: MessageTarget, node: TreeNode[EventTreeDataType] + ) -> None: + self.node = node + super().__init__(sender) + def __init__( self, label: TextType, @@ -457,6 +478,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.invalidate() self._line_cache.grow(self.size.height * 2) + def _on_click(self, event: events.Click) -> None: + meta = event.style.meta + if "line" in meta: + self.cursor_line = meta["line"] + def action_cursor_up(self) -> None: if self.cursor_line == -1: self.cursor_line = len(self._tree_lines) @@ -466,7 +492,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def action_cursor_down(self) -> None: self.cursor_line += 1 - def _on_click(self, event: events.Click) -> None: - meta = event.style.meta - if "line" in meta: - self.cursor_line = meta["line"] + def action_select_cursor(self) -> None: + try: + line = self._tree_lines[self.cursor_line] + except IndexError: + pass + else: + self.emit_no_wait(self.NodeSelected(self, line.path[-1])) + self.app.bell() From 3d35a602b5cd471101bd812a9da7db61a319c2c1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Nov 2022 15:15:42 +0000 Subject: [PATCH 12/33] added node expanding --- src/textual/widgets/_tree.py | 56 +++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 7ce6b957b..2d42a7d3c 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -15,7 +15,7 @@ from ..geometry import clamp, Region, Size from .._loop import loop_last from .._cache import LRUCache from ..message import Message -from ..reactive import reactive +from ..reactive import reactive, var from .._segment_tools import line_crop, line_pad from .._types import MessageTarget from .._typing import TypeAlias @@ -47,7 +47,7 @@ class _TreeLine: class TreeNode(Generic[TreeDataType]): def __init__( self, - tree: Tree, + tree: Tree[TreeDataType], parent: TreeNode[TreeDataType] | None, id: NodeID, label: Text, @@ -60,7 +60,7 @@ class TreeNode(Generic[TreeDataType]): self.id = id self.label = label self.data: TreeDataType = data - self.expanded = expanded + self._expanded = expanded self.children: list[TreeNode] = [] self._hover = False @@ -70,6 +70,15 @@ class TreeNode(Generic[TreeDataType]): yield self.label.plain yield self.data + @property + def expanded(self) -> bool: + return self._expanded + + @expanded.setter + def expanded(self, expanded: bool) -> None: + self._expanded = expanded + self._tree.invalidate() + @property def last(self) -> bool: """Check if this is the last child. @@ -101,7 +110,7 @@ class TreeNode(Generic[TreeDataType]): else: text_label = label node = self._tree._add_node(self, text_label, data) - node.expanded = expanded + node._expanded = expanded self.children.append(node) self._tree.invalidate() return node @@ -165,9 +174,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): "tree--highlight-line", } - hover_line: reactive[int] = reactive(-1, repaint=False) - cursor_line: reactive[int] = reactive(0, repaint=False) - guide_depth: reactive[int] = reactive(4, repaint=False, init=False) + hover_line = var(-1) + cursor_line = var(-1) + guide_depth = var(4, init=False) + auto_expand = var(True) LINES: dict[str, tuple[str, str, str, str]] = { "default": ( @@ -216,7 +226,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._current_id = 0 self.root = self._add_node(None, text_label, data) - self.root.expanded = True + self.root._expanded = True self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None @@ -228,6 +238,17 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._updates += 1 return node + def render_label(self, node: TreeNode[TreeDataType]) -> Text: + """Render a label for the given node. Override this to modify how labels are rendered. + + Args: + node (TreeNode[TreeDataType]): A tree node. + + Returns: + Text: A Rich Text object containing the label. + """ + return node.label + def clear(self) -> None: """Clear all nodes under root.""" self._tree_lines_cached = None @@ -349,7 +370,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None: child_path = [*path, node] add_line(_TreeLine(child_path, last)) - if node.expanded: + if node._expanded: for last, child in loop_last(node.children): add_node(child_path, child, last=last) @@ -455,7 +476,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): guides.append(terminator, style=guide_style) else: guides.append(cross, style=guide_style) - label = line.path[-1].label.copy() + + label = self.render_label(line.path[-1]).copy() label.stylize(self.get_component_rich_style("tree--label", partial=True)) if self.hover_line == y: label.stylize( @@ -478,14 +500,18 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.invalidate() self._line_cache.grow(self.size.height * 2) - def _on_click(self, event: events.Click) -> None: + async def _on_click(self, event: events.Click) -> None: meta = event.style.meta if "line" in meta: - self.cursor_line = meta["line"] + cursor_line = meta["line"] + if self.cursor_line == cursor_line: + await self.action("select_cursor") + else: + self.cursor_line = cursor_line def action_cursor_up(self) -> None: if self.cursor_line == -1: - self.cursor_line = len(self._tree_lines) + self.cursor_line = len(self._tree_lines) - 1 else: self.cursor_line -= 1 @@ -498,5 +524,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): except IndexError: pass else: + node = line.path[-1] + if self.auto_expand: + node.expanded = not node.expanded self.emit_no_wait(self.NodeSelected(self, line.path[-1])) - self.app.bell() From d9a0da91a4fe6e8ab20e8c0b259272d5d877afe8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 17 Nov 2022 16:59:29 +0000 Subject: [PATCH 13/33] fixed some rendering glitches --- examples/food.json | 1944 ++++++++++++++++++++++++++++++++++ examples/tree.py | 32 + src/textual/widget.py | 14 +- src/textual/widgets/_tree.py | 294 +++-- 4 files changed, 2184 insertions(+), 100 deletions(-) create mode 100644 examples/food.json create mode 100644 examples/tree.py diff --git a/examples/food.json b/examples/food.json new file mode 100644 index 000000000..d64d2f1ad --- /dev/null +++ b/examples/food.json @@ -0,0 +1,1944 @@ +{ + "code": "5060292302201", + "product": { + "_id": "5060292302201", + "_keywords": [ + "snack", + "food", + "oil", + "anything", + "potato", + "appetizer", + "artificial", + "plant-based", + "cereal", + "and", + "in", + "popchip", + "preservative", + "barbeque", + "vegetarian", + "sunflower", + "chip", + "frie", + "potatoe", + "no", + "crisp", + "beverage", + "salty" + ], + "added_countries_tags": [], + "additives_debug_tags": [], + "additives_n": 2, + "additives_old_n": 2, + "additives_old_tags": [ + "en:e330", + "en:e160c" + ], + "additives_original_tags": [ + "en:e330", + "en:e160c" + ], + "additives_prev_original_tags": [ + "en:e330", + "en:e160c" + ], + "additives_tags": [ + "en:e160c", + "en:e330" + ], + "additives_tags_n": null, + "allergens": "en:milk", + "allergens_debug_tags": [], + "allergens_from_ingredients": "en:milk, milk", + "allergens_from_user": "(en) en:milk", + "allergens_hierarchy": [ + "en:milk" + ], + "allergens_tags": [ + "en:milk" + ], + "amino_acids_prev_tags": [], + "amino_acids_tags": [], + "brands": "Popchips", + "brands_debug_tags": [], + "brands_tags": [ + "popchips" + ], + "carbon_footprint_from_known_ingredients_debug": "en:potato 54% x 0.6 = 32.4 g - ", + "carbon_footprint_percent_of_known_ingredients": 54, + "categories": "Plant-based foods and beverages, Plant-based foods, Snacks, Cereals and potatoes, Salty snacks, Appetizers, Chips and fries, Crisps, Potato crisps, Potato crisps in sunflower oil", + "categories_hierarchy": [ + "en:plant-based-foods-and-beverages", + "en:plant-based-foods", + "en:snacks", + "en:cereals-and-potatoes", + "en:salty-snacks", + "en:appetizers", + "en:chips-and-fries", + "en:crisps", + "en:potato-crisps", + "en:potato-crisps-in-sunflower-oil" + ], + "categories_lc": "en", + "categories_old": "Plant-based foods and beverages, Plant-based foods, Snacks, Cereals and potatoes, Salty snacks, Appetizers, Chips and fries, Crisps, Potato crisps, Potato crisps in sunflower oil", + "categories_properties": { + "agribalyse_food_code:en": "4004", + "ciqual_food_code:en": "4004" + }, + "categories_properties_tags": [ + "all-products", + "categories-known", + "agribalyse-food-code-4004", + "agribalyse-food-code-known", + "agribalyse-proxy-food-code-unknown", + "ciqual-food-code-4004", + "ciqual-food-code-known", + "agribalyse-known", + "agribalyse-4004" + ], + "categories_tags": [ + "en:plant-based-foods-and-beverages", + "en:plant-based-foods", + "en:snacks", + "en:cereals-and-potatoes", + "en:salty-snacks", + "en:appetizers", + "en:chips-and-fries", + "en:crisps", + "en:potato-crisps", + "en:potato-crisps-in-sunflower-oil" + ], + "category_properties": { + "ciqual_food_name:en": "Potato crisps", + "ciqual_food_name:fr": "Chips de pommes de terre, standard" + }, + "checkers_tags": [], + "ciqual_food_name_tags": [ + "potato-crisps" + ], + "cities_tags": [], + "code": "5060292302201", + "codes_tags": [ + "code-13", + "5060292302xxx", + "506029230xxxx", + "50602923xxxxx", + "5060292xxxxxx", + "506029xxxxxxx", + "50602xxxxxxxx", + "5060xxxxxxxxx", + "506xxxxxxxxxx", + "50xxxxxxxxxxx", + "5xxxxxxxxxxxx" + ], + "compared_to_category": "en:potato-crisps-in-sunflower-oil", + "complete": 0, + "completeness": 0.8875, + "correctors_tags": [ + "tacite", + "tacite-mass-editor", + "yuka.VjQwdU5yUUlpdmxUbjhWa3BFenc4ZGt1NDVLUFZtNm9NdWdOSWc9PQ", + "openfoodfacts-contributors", + "swipe-studio", + "yuka.sY2b0xO6T85zoF3NwEKvllZnctbb-gn-LDr4mHzUyem0FYPXMO5by7b5NKg", + "kiliweb", + "packbot", + "foodless", + "yuka.sY2b0xO6T85zoF3NwEKvlmBZVPXu-gnlBU3miFTQ-NeSIbDaMdUtu4fLGas" + ], + "countries": "France, United Kingdom", + "countries_debug_tags": [], + "countries_hierarchy": [ + "en:france", + "en:united-kingdom" + ], + "countries_lc": "en", + "countries_tags": [ + "en:france", + "en:united-kingdom" + ], + "created_t": 1433338177, + "creator": "kyzh", + "data_quality_bugs_tags": [], + "data_quality_errors_tags": [], + "data_quality_info_tags": [ + "en:packaging-data-incomplete", + "en:ingredients-percent-analysis-ok", + "en:carbon-footprint-from-known-ingredients-but-not-from-meat-or-fish", + "en:ecoscore-extended-data-computed", + "en:ecoscore-extended-data-less-precise-than-agribalyse", + "en:food-groups-1-known", + "en:food-groups-2-known", + "en:food-groups-3-unknown" + ], + "data_quality_tags": [ + "en:packaging-data-incomplete", + "en:ingredients-percent-analysis-ok", + "en:carbon-footprint-from-known-ingredients-but-not-from-meat-or-fish", + "en:ecoscore-extended-data-computed", + "en:ecoscore-extended-data-less-precise-than-agribalyse", + "en:food-groups-1-known", + "en:food-groups-2-known", + "en:food-groups-3-unknown", + "en:nutrition-value-very-low-for-category-energy", + "en:nutrition-value-very-low-for-category-fat", + "en:nutrition-value-very-low-for-category-carbohydrates", + "en:nutrition-value-very-high-for-category-sugars", + "en:ecoscore-origins-of-ingredients-origins-are-100-percent-unknown", + "en:ecoscore-production-system-no-label" + ], + "data_quality_warnings_tags": [ + "en:nutrition-value-very-low-for-category-energy", + "en:nutrition-value-very-low-for-category-fat", + "en:nutrition-value-very-low-for-category-carbohydrates", + "en:nutrition-value-very-high-for-category-sugars", + "en:ecoscore-origins-of-ingredients-origins-are-100-percent-unknown", + "en:ecoscore-production-system-no-label" + ], + "data_sources": "App - yuka, Apps, App - Horizon", + "data_sources_tags": [ + "app-yuka", + "apps", + "app-horizon" + ], + "debug_param_sorted_langs": [ + "en", + "fr" + ], + "ecoscore_data": { + "adjustments": { + "origins_of_ingredients": { + "aggregated_origins": [ + { + "origin": "en:unknown", + "percent": 100 + } + ], + "epi_score": 0, + "epi_value": -5, + "origins_from_origins_field": [ + "en:unknown" + ], + "transportation_scores": { + "ad": 0, + "al": 0, + "at": 0, + "ax": 0, + "ba": 0, + "be": 0, + "bg": 0, + "ch": 0, + "cy": 0, + "cz": 0, + "de": 0, + "dk": 0, + "dz": 0, + "ee": 0, + "eg": 0, + "es": 0, + "fi": 0, + "fo": 0, + "fr": 0, + "gg": 0, + "gi": 0, + "gr": 0, + "hr": 0, + "hu": 0, + "ie": 0, + "il": 0, + "im": 0, + "is": 0, + "it": 0, + "je": 0, + "lb": 0, + "li": 0, + "lt": 0, + "lu": 0, + "lv": 0, + "ly": 0, + "ma": 0, + "mc": 0, + "md": 0, + "me": 0, + "mk": 0, + "mt": 0, + "nl": 0, + "no": 0, + "pl": 0, + "ps": 0, + "pt": 0, + "ro": 0, + "rs": 0, + "se": 0, + "si": 0, + "sj": 0, + "sk": 0, + "sm": 0, + "sy": 0, + "tn": 0, + "tr": 0, + "ua": 0, + "uk": 0, + "us": 0, + "va": 0, + "world": 0, + "xk": 0 + }, + "transportation_values": { + "ad": 0, + "al": 0, + "at": 0, + "ax": 0, + "ba": 0, + "be": 0, + "bg": 0, + "ch": 0, + "cy": 0, + "cz": 0, + "de": 0, + "dk": 0, + "dz": 0, + "ee": 0, + "eg": 0, + "es": 0, + "fi": 0, + "fo": 0, + "fr": 0, + "gg": 0, + "gi": 0, + "gr": 0, + "hr": 0, + "hu": 0, + "ie": 0, + "il": 0, + "im": 0, + "is": 0, + "it": 0, + "je": 0, + "lb": 0, + "li": 0, + "lt": 0, + "lu": 0, + "lv": 0, + "ly": 0, + "ma": 0, + "mc": 0, + "md": 0, + "me": 0, + "mk": 0, + "mt": 0, + "nl": 0, + "no": 0, + "pl": 0, + "ps": 0, + "pt": 0, + "ro": 0, + "rs": 0, + "se": 0, + "si": 0, + "sj": 0, + "sk": 0, + "sm": 0, + "sy": 0, + "tn": 0, + "tr": 0, + "ua": 0, + "uk": 0, + "us": 0, + "va": 0, + "world": 0, + "xk": 0 + }, + "values": { + "ad": -5, + "al": -5, + "at": -5, + "ax": -5, + "ba": -5, + "be": -5, + "bg": -5, + "ch": -5, + "cy": -5, + "cz": -5, + "de": -5, + "dk": -5, + "dz": -5, + "ee": -5, + "eg": -5, + "es": -5, + "fi": -5, + "fo": -5, + "fr": -5, + "gg": -5, + "gi": -5, + "gr": -5, + "hr": -5, + "hu": -5, + "ie": -5, + "il": -5, + "im": -5, + "is": -5, + "it": -5, + "je": -5, + "lb": -5, + "li": -5, + "lt": -5, + "lu": -5, + "lv": -5, + "ly": -5, + "ma": -5, + "mc": -5, + "md": -5, + "me": -5, + "mk": -5, + "mt": -5, + "nl": -5, + "no": -5, + "pl": -5, + "ps": -5, + "pt": -5, + "ro": -5, + "rs": -5, + "se": -5, + "si": -5, + "sj": -5, + "sk": -5, + "sm": -5, + "sy": -5, + "tn": -5, + "tr": -5, + "ua": -5, + "uk": -5, + "us": -5, + "va": -5, + "world": -5, + "xk": -5 + }, + "warning": "origins_are_100_percent_unknown" + }, + "packaging": { + "non_recyclable_and_non_biodegradable_materials": 1, + "packagings": [ + { + "ecoscore_material_score": 0, + "ecoscore_shape_ratio": 1, + "material": "en:plastic", + "non_recyclable_and_non_biodegradable": "maybe", + "shape": "en:pack" + } + ], + "score": 0, + "value": -10 + }, + "production_system": { + "labels": [], + "value": 0, + "warning": "no_label" + }, + "threatened_species": {} + }, + "agribalyse": { + "agribalyse_food_code": "4004", + "co2_agriculture": 1.2992636, + "co2_consumption": 0, + "co2_distribution": 0.029120657, + "co2_packaging": 0.28581962, + "co2_processing": 0.39294234, + "co2_total": 2.2443641, + "co2_transportation": 0.23728203, + "code": "4004", + "dqr": "2.45", + "ef_agriculture": 0.18214682, + "ef_consumption": 0, + "ef_distribution": 0.0098990521, + "ef_packaging": 0.021558384, + "ef_processing": 0.057508389, + "ef_total": 0.29200269, + "ef_transportation": 0.020894187, + "is_beverage": 0, + "name_en": "Potato crisps", + "name_fr": "Chips de pommes de terre, standard", + "score": 78 + }, + "grade": "b", + "grades": { + "ad": "b", + "al": "b", + "at": "b", + "ax": "b", + "ba": "b", + "be": "b", + "bg": "b", + "ch": "b", + "cy": "b", + "cz": "b", + "de": "b", + "dk": "b", + "dz": "b", + "ee": "b", + "eg": "b", + "es": "b", + "fi": "b", + "fo": "b", + "fr": "b", + "gg": "b", + "gi": "b", + "gr": "b", + "hr": "b", + "hu": "b", + "ie": "b", + "il": "b", + "im": "b", + "is": "b", + "it": "b", + "je": "b", + "lb": "b", + "li": "b", + "lt": "b", + "lu": "b", + "lv": "b", + "ly": "b", + "ma": "b", + "mc": "b", + "md": "b", + "me": "b", + "mk": "b", + "mt": "b", + "nl": "b", + "no": "b", + "pl": "b", + "ps": "b", + "pt": "b", + "ro": "b", + "rs": "b", + "se": "b", + "si": "b", + "sj": "b", + "sk": "b", + "sm": "b", + "sy": "b", + "tn": "b", + "tr": "b", + "ua": "b", + "uk": "b", + "us": "b", + "va": "b", + "world": "b", + "xk": "b" + }, + "missing": { + "labels": 1, + "origins": 1 + }, + "missing_data_warning": 1, + "score": 63, + "scores": { + "ad": 63, + "al": 63, + "at": 63, + "ax": 63, + "ba": 63, + "be": 63, + "bg": 63, + "ch": 63, + "cy": 63, + "cz": 63, + "de": 63, + "dk": 63, + "dz": 63, + "ee": 63, + "eg": 63, + "es": 63, + "fi": 63, + "fo": 63, + "fr": 63, + "gg": 63, + "gi": 63, + "gr": 63, + "hr": 63, + "hu": 63, + "ie": 63, + "il": 63, + "im": 63, + "is": 63, + "it": 63, + "je": 63, + "lb": 63, + "li": 63, + "lt": 63, + "lu": 63, + "lv": 63, + "ly": 63, + "ma": 63, + "mc": 63, + "md": 63, + "me": 63, + "mk": 63, + "mt": 63, + "nl": 63, + "no": 63, + "pl": 63, + "ps": 63, + "pt": 63, + "ro": 63, + "rs": 63, + "se": 63, + "si": 63, + "sj": 63, + "sk": 63, + "sm": 63, + "sy": 63, + "tn": 63, + "tr": 63, + "ua": 63, + "uk": 63, + "us": 63, + "va": 63, + "world": 63, + "xk": 63 + }, + "status": "known" + }, + "ecoscore_extended_data": { + "impact": { + "ef_single_score_log_stddev": 0.0664290643574977, + "likeliest_impacts": { + "Climate_change": 0.0835225930657116, + "EF_single_score": 0.0132996566234689 + }, + "likeliest_recipe": { + "en:Oak_smoked_sea_salti_yeast_extract": 0.103505496656251, + "en:e160c": 0.10350549665625, + "en:e330": 0.10350549665625, + "en:flavouring": 0.10350549665625, + "en:garlic_powder": 0.103505496656251, + "en:milk": 1.55847864453775, + "en:onion": 0.15510736429208, + "en:potato": 69.2208020730349, + "en:potato_starch": 10.5320407294931, + "en:rice_flour": 13.8595510001351, + "en:salt": 1.3345917157533, + "en:spice": 0.10350549665625, + "en:sugar": 10.2883618334396, + "en:sunflower_oil": 14.1645835312727, + "en:tomato_powder": 0.10350549665625, + "en:water": 6.24510964041154, + "en:yeast_powder": 0.103505496656251 + }, + "mass_ratio_uncharacterized": 0.0244618467395455, + "uncharacterized_ingredients": { + "impact": [ + "en:yeast-powder", + "en:flavouring", + "en:Oak smoked sea salti yeast extract", + "en:e160c", + "en:e330" + ], + "nutrition": [ + "en:flavouring", + "en:Oak smoked sea salti yeast extract" + ] + }, + "uncharacterized_ingredients_mass_proportion": { + "impact": 0.0244618467395455, + "nutrition": 0.0106506947223728 + }, + "uncharacterized_ingredients_ratio": { + "impact": 0.3125, + "nutrition": 0.125 + }, + "warnings": [ + "Fermentation agents are present in the product (en:yeast-powder). Carbohydrates and sugars mass balance will not be considered to estimate potential recipes", + "The product has a high number of impact uncharacterized ingredients: 31%" + ] + } + }, + "ecoscore_extended_data_version": "4", + "ecoscore_grade": "b", + "ecoscore_score": 63, + "ecoscore_tags": [ + "b" + ], + "editors": [ + "kyzh", + "tacite" + ], + "editors_tags": [ + "yuka.VjQwdU5yUUlpdmxUbjhWa3BFenc4ZGt1NDVLUFZtNm9NdWdOSWc9PQ", + "yuka.sY2b0xO6T85zoF3NwEKvllZnctbb-gn-LDr4mHzUyem0FYPXMO5by7b5NKg", + "tacite", + "kyzh", + "foodless", + "packbot", + "openfoodfacts-contributors", + "kiliweb", + "yuka.sY2b0xO6T85zoF3NwEKvlmBZVPXu-gnlBU3miFTQ-NeSIbDaMdUtu4fLGas", + "ecoscore-impact-estimator", + "swipe-studio", + "tacite-mass-editor" + ], + "emb_codes": "", + "emb_codes_20141016": "", + "emb_codes_debug_tags": [], + "emb_codes_orig": "", + "emb_codes_tags": [], + "entry_dates_tags": [ + "2015-06-03", + "2015-06", + "2015" + ], + "expiration_date": "11/05/2016", + "expiration_date_debug_tags": [], + "food_groups": "en:appetizers", + "food_groups_tags": [ + "en:salty-snacks", + "en:appetizers" + ], + "fruits-vegetables-nuts_100g_estimate": 0, + "generic_name": "", + "generic_name_en": "", + "generic_name_en_debug_tags": [], + "generic_name_fr": "", + "generic_name_fr_debug_tags": [], + "id": "5060292302201", + "image_front_small_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.200.jpg", + "image_front_thumb_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.100.jpg", + "image_front_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.400.jpg", + "image_ingredients_small_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.200.jpg", + "image_ingredients_thumb_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.100.jpg", + "image_ingredients_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.400.jpg", + "image_nutrition_small_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.200.jpg", + "image_nutrition_thumb_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.100.jpg", + "image_nutrition_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.400.jpg", + "image_small_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.200.jpg", + "image_thumb_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.100.jpg", + "image_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.400.jpg", + "images": { + "1": { + "sizes": { + "100": { + "h": 74, + "w": 100 + }, + "400": { + "h": 296, + "w": 400 + }, + "full": { + "h": 1482, + "w": 2000 + } + }, + "uploaded_t": 1433338177, + "uploader": "kyzh" + }, + "2": { + "sizes": { + "100": { + "h": 74, + "w": 100 + }, + "400": { + "h": 296, + "w": 400 + }, + "full": { + "h": 1482, + "w": 2000 + } + }, + "uploaded_t": 1433338194, + "uploader": "kyzh" + }, + "3": { + "sizes": { + "100": { + "h": 74, + "w": 100 + }, + "400": { + "h": 296, + "w": 400 + }, + "full": { + "h": 1482, + "w": 2000 + } + }, + "uploaded_t": 1433338203, + "uploader": "kyzh" + }, + "4": { + "sizes": { + "100": { + "h": 74, + "w": 100 + }, + "400": { + "h": 296, + "w": 400 + }, + "full": { + "h": 1482, + "w": 2000 + } + }, + "uploaded_t": 1433338215, + "uploader": "kyzh" + }, + "5": { + "sizes": { + "100": { + "h": 74, + "w": 100 + }, + "400": { + "h": 296, + "w": 400 + }, + "full": { + "h": 1482, + "w": 2000 + } + }, + "uploaded_t": 1433338229, + "uploader": "kyzh" + }, + "6": { + "sizes": { + "100": { + "h": 74, + "w": 100 + }, + "400": { + "h": 296, + "w": 400 + }, + "full": { + "h": 1482, + "w": 2000 + } + }, + "uploaded_t": 1433338245, + "uploader": "kyzh" + }, + "7": { + "sizes": { + "100": { + "h": 43, + "w": 100 + }, + "400": { + "h": 171, + "w": 400 + }, + "full": { + "h": 846, + "w": 1974 + } + }, + "uploaded_t": "1508236270", + "uploader": "kiliweb" + }, + "8": { + "sizes": { + "100": { + "h": 100, + "w": 82 + }, + "400": { + "h": 400, + "w": 326 + }, + "full": { + "h": 1140, + "w": 930 + } + }, + "uploaded_t": 1620505759, + "uploader": "kiliweb" + }, + "9": { + "sizes": { + "100": { + "h": 56, + "w": 100 + }, + "400": { + "h": 225, + "w": 400 + }, + "full": { + "h": 569, + "w": 1011 + } + }, + "uploaded_t": 1656075071, + "uploader": "kiliweb" + }, + "front": { + "geometry": "1421x1825-0-95", + "imgid": "1", + "normalize": "false", + "rev": "9", + "sizes": { + "100": { + "h": 100, + "w": 78 + }, + "200": { + "h": 200, + "w": 156 + }, + "400": { + "h": 400, + "w": 311 + }, + "full": { + "h": 1825, + "w": 1421 + } + }, + "white_magic": "true" + }, + "front_en": { + "angle": 0, + "coordinates_image_size": "full", + "geometry": "0x0--1--1", + "imgid": "8", + "normalize": null, + "rev": "23", + "sizes": { + "100": { + "h": 100, + "w": 82 + }, + "200": { + "h": 200, + "w": 163 + }, + "400": { + "h": 400, + "w": 326 + }, + "full": { + "h": 1140, + "w": 930 + } + }, + "white_magic": null, + "x1": "-1", + "x2": "-1", + "y1": "-1", + "y2": "-1" + }, + "ingredients": { + "geometry": "1730x526-125-304", + "imgid": "5", + "normalize": "false", + "ocr": 1, + "orientation": "0", + "rev": "11", + "sizes": { + "100": { + "h": 30, + "w": 100 + }, + "200": { + "h": 61, + "w": 200 + }, + "400": { + "h": 122, + "w": 400 + }, + "full": { + "h": 526, + "w": 1730 + } + }, + "white_magic": "false" + }, + "ingredients_en": { + "geometry": "1730x526-125-304", + "imgid": "5", + "normalize": "false", + "ocr": 1, + "orientation": "0", + "rev": "11", + "sizes": { + "100": { + "h": 30, + "w": 100 + }, + "200": { + "h": 61, + "w": 200 + }, + "400": { + "h": 122, + "w": 400 + }, + "full": { + "h": 526, + "w": 1730 + } + }, + "white_magic": "false" + }, + "nutrition": { + "geometry": "1131x920-150-794", + "imgid": "3", + "normalize": "false", + "ocr": 1, + "orientation": "0", + "rev": "10", + "sizes": { + "100": { + "h": 81, + "w": 100 + }, + "200": { + "h": 163, + "w": 200 + }, + "400": { + "h": 325, + "w": 400 + }, + "full": { + "h": 920, + "w": 1131 + } + }, + "white_magic": "false" + }, + "nutrition_en": { + "angle": 0, + "coordinates_image_size": "full", + "geometry": "0x0--1--1", + "imgid": "9", + "normalize": null, + "rev": "32", + "sizes": { + "100": { + "h": 56, + "w": 100 + }, + "200": { + "h": 113, + "w": 200 + }, + "400": { + "h": 225, + "w": 400 + }, + "full": { + "h": 569, + "w": 1011 + } + }, + "white_magic": null, + "x1": "-1", + "x2": "-1", + "y1": "-1", + "y2": "-1" + } + }, + "informers_tags": [ + "kyzh", + "tacite", + "tacite-mass-editor", + "yuka.VjQwdU5yUUlpdmxUbjhWa3BFenc4ZGt1NDVLUFZtNm9NdWdOSWc9PQ", + "openfoodfacts-contributors" + ], + "ingredients": [ + { + "id": "en:potato", + "percent": 54, + "percent_estimate": 54, + "percent_max": 54, + "percent_min": 54, + "processing": "en:dried", + "rank": 1, + "text": "potatoes", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "from_palm_oil": "no", + "id": "en:sunflower-oil", + "percent_estimate": 28.75, + "percent_max": 46, + "percent_min": 11.5, + "rank": 2, + "text": "sunflower oil", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "has_sub_ingredients": "yes", + "id": "en:coating", + "percent_estimate": 8.625, + "percent_max": 33.3333333333333, + "percent_min": 0, + "rank": 3, + "text": "seasoning", + "vegan": "ignore", + "vegetarian": "ignore" + }, + { + "id": "en:rice-flour", + "percent_estimate": 4.3125, + "percent_max": 17.25, + "percent_min": 0, + "rank": 4, + "text": "rice flour", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "id": "en:potato-starch", + "percent_estimate": 4.3125, + "percent_max": 11.5, + "percent_min": 0, + "rank": 5, + "text": "potato starch", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "id": "en:sugar", + "percent_estimate": 4.3125, + "percent_max": 33.3333333333333, + "percent_min": 0, + "text": "sugar", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "has_sub_ingredients": "yes", + "id": "en:whey-powder", + "percent_estimate": 2.15625, + "percent_max": 16.6666666666667, + "percent_min": 0, + "text": "whey powder", + "vegan": "no", + "vegetarian": "maybe" + }, + { + "id": "en:salt", + "percent_estimate": 1.078125, + "percent_max": 11.1111111111111, + "percent_min": 0, + "text": "salt", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "id": "en:onion", + "percent_estimate": 0.5390625, + "percent_max": 8.33333333333333, + "percent_min": 0, + "processing": "en:powder", + "text": "onion", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "id": "en:yeast-powder", + "percent_estimate": 0.26953125, + "percent_max": 6.66666666666667, + "percent_min": 0, + "text": "yeast powder", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "id": "en:garlic-powder", + "percent_estimate": 0.134765625, + "percent_max": 5.55555555555556, + "percent_min": 0, + "text": "garlic powder", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "id": "en:tomato-powder", + "percent_estimate": 0.0673828125, + "percent_max": 4.76190476190476, + "percent_min": 0, + "text": "tomato powder", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "id": "en:oak-smoked-sea-salti-yeast-extract", + "percent_estimate": 0.03369140625, + "percent_max": 4.16666666666667, + "percent_min": 0, + "text": "Oak smoked sea salti yeast extract" + }, + { + "id": "en:flavouring", + "percent_estimate": 0.016845703125, + "percent_max": 3.7037037037037, + "percent_min": 0, + "text": "flavourings", + "vegan": "maybe", + "vegetarian": "maybe" + }, + { + "id": "en:spice", + "percent_estimate": 0.0084228515625, + "percent_max": 3.33333333333333, + "percent_min": 0, + "text": "spices", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "has_sub_ingredients": "yes", + "id": "en:acid", + "percent_estimate": 0.00421142578125, + "percent_max": 3.03030303030303, + "percent_min": 0, + "text": "acid" + }, + { + "has_sub_ingredients": "yes", + "id": "en:colour", + "percent_estimate": 0.00421142578125, + "percent_max": 2.77777777777778, + "percent_min": 0, + "text": "colour" + }, + { + "id": "en:milk", + "percent_estimate": 2.15625, + "percent_max": 16.6666666666667, + "percent_min": 0, + "text": "milk", + "vegan": "no", + "vegetarian": "yes" + }, + { + "id": "en:e330", + "percent_estimate": 0.00421142578125, + "percent_max": 3.03030303030303, + "percent_min": 0, + "text": "citric acid", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "id": "en:e160c", + "percent_estimate": 0.00421142578125, + "percent_max": 2.77777777777778, + "percent_min": 0, + "text": "paprika extract", + "vegan": "yes", + "vegetarian": "yes" + } + ], + "ingredients_analysis": { + "en:non-vegan": [ + "en:whey-powder", + "en:milk" + ], + "en:palm-oil-content-unknown": [ + "en:oak-smoked-sea-salti-yeast-extract" + ], + "en:vegan-status-unknown": [ + "en:oak-smoked-sea-salti-yeast-extract" + ], + "en:vegetarian-status-unknown": [ + "en:oak-smoked-sea-salti-yeast-extract" + ] + }, + "ingredients_analysis_tags": [ + "en:palm-oil-free", + "en:non-vegan", + "en:vegetarian" + ], + "ingredients_debug": [ + "54% dried potatoes", + ",", + null, + null, + null, + " sunflower oil", + ",", + null, + null, + null, + " seasoning ", + "(", + "(", + null, + null, + "sugar", + ",", + null, + null, + null, + " whey powder ", + "[", + "[", + null, + null, + "milk]", + ",", + null, + null, + null, + " salt", + ",", + null, + null, + null, + " onion powder", + ",", + null, + null, + null, + " yeast powder", + ",", + null, + null, + null, + " garlic powder", + ",", + null, + null, + null, + " tomato powder", + ",", + null, + null, + null, + " Oak smoked sea salti yeast extract", + ",", + null, + null, + null, + " flavourings", + ",", + null, + null, + null, + " spices", + ",", + null, + null, + null, + " acid", + ":", + ":", + null, + null, + " citric acid", + ",", + null, + null, + null, + " colour", + ":", + ":", + null, + null, + " paprika extract)", + ",", + null, + null, + null, + " rice flour", + ",", + null, + null, + null, + " potato starch." + ], + "ingredients_from_or_that_may_be_from_palm_oil_n": 0, + "ingredients_from_palm_oil_n": 0, + "ingredients_from_palm_oil_tags": [], + "ingredients_hierarchy": [ + "en:potato", + "en:vegetable", + "en:root-vegetable", + "en:sunflower-oil", + "en:oil-and-fat", + "en:vegetable-oil-and-fat", + "en:vegetable-oil", + "en:coating", + "en:rice-flour", + "en:flour", + "en:rice", + "en:potato-starch", + "en:starch", + "en:sugar", + "en:added-sugar", + "en:disaccharide", + "en:whey-powder", + "en:dairy", + "en:whey", + "en:salt", + "en:onion", + "en:yeast-powder", + "en:yeast", + "en:garlic-powder", + "en:garlic", + "en:tomato-powder", + "en:tomato", + "en:oak-smoked-sea-salti-yeast-extract", + "en:flavouring", + "en:spice", + "en:condiment", + "en:acid", + "en:colour", + "en:milk", + "en:e330", + "en:e160c" + ], + "ingredients_ids_debug": [ + "54-dried-potatoes", + "sunflower-oil", + "seasoning", + "sugar", + "whey-powder", + "milk", + "salt", + "onion-powder", + "yeast-powder", + "garlic-powder", + "tomato-powder", + "oak-smoked-sea-salti-yeast-extract", + "flavourings", + "spices", + "acid", + "citric-acid", + "colour", + "paprika-extract", + "rice-flour", + "potato-starch" + ], + "ingredients_n": 20, + "ingredients_n_tags": [ + "20", + "11-20" + ], + "ingredients_original_tags": [ + "en:potato", + "en:sunflower-oil", + "en:coating", + "en:rice-flour", + "en:potato-starch", + "en:sugar", + "en:whey-powder", + "en:salt", + "en:onion", + "en:yeast-powder", + "en:garlic-powder", + "en:tomato-powder", + "en:oak-smoked-sea-salti-yeast-extract", + "en:flavouring", + "en:spice", + "en:acid", + "en:colour", + "en:milk", + "en:e330", + "en:e160c" + ], + "ingredients_percent_analysis": 1, + "ingredients_tags": [ + "en:potato", + "en:vegetable", + "en:root-vegetable", + "en:sunflower-oil", + "en:oil-and-fat", + "en:vegetable-oil-and-fat", + "en:vegetable-oil", + "en:coating", + "en:rice-flour", + "en:flour", + "en:rice", + "en:potato-starch", + "en:starch", + "en:sugar", + "en:added-sugar", + "en:disaccharide", + "en:whey-powder", + "en:dairy", + "en:whey", + "en:salt", + "en:onion", + "en:yeast-powder", + "en:yeast", + "en:garlic-powder", + "en:garlic", + "en:tomato-powder", + "en:tomato", + "en:oak-smoked-sea-salti-yeast-extract", + "en:flavouring", + "en:spice", + "en:condiment", + "en:acid", + "en:colour", + "en:milk", + "en:e330", + "en:e160c" + ], + "ingredients_text": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.", + "ingredients_text_debug": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.", + "ingredients_text_debug_tags": [], + "ingredients_text_en": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.", + "ingredients_text_fr": "", + "ingredients_text_fr_debug_tags": [], + "ingredients_text_with_allergens": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.", + "ingredients_text_with_allergens_en": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.", + "ingredients_that_may_be_from_palm_oil_n": 0, + "ingredients_that_may_be_from_palm_oil_tags": [], + "ingredients_with_specified_percent_n": 1, + "ingredients_with_specified_percent_sum": 54, + "ingredients_with_unspecified_percent_n": 15, + "ingredients_with_unspecified_percent_sum": 46, + "interface_version_created": "20120622", + "interface_version_modified": "20150316.jqm2", + "known_ingredients_n": 35, + "labels": "Vegetarian, No preservatives, No artificial anything", + "labels_hierarchy": [ + "en:vegetarian", + "en:no-preservatives", + "en:No artificial anything" + ], + "labels_lc": "en", + "labels_old": "Vegetarian, No preservatives, No artificial anything", + "labels_tags": [ + "en:vegetarian", + "en:no-preservatives", + "en:no-artificial-anything" + ], + "lang": "en", + "lang_debug_tags": [], + "languages": { + "en:english": 5 + }, + "languages_codes": { + "en": 5 + }, + "languages_hierarchy": [ + "en:english" + ], + "languages_tags": [ + "en:english", + "en:1" + ], + "last_edit_dates_tags": [ + "2022-06-24", + "2022-06", + "2022" + ], + "last_editor": "kiliweb", + "last_image_dates_tags": [ + "2022-06-24", + "2022-06", + "2022" + ], + "last_image_t": 1656075071, + "last_modified_by": "kiliweb", + "last_modified_t": 1656075071, + "lc": "en", + "link": "", + "link_debug_tags": [], + "main_countries_tags": [], + "manufacturing_places": "European Union", + "manufacturing_places_debug_tags": [], + "manufacturing_places_tags": [ + "european-union" + ], + "max_imgid": "9", + "minerals_prev_tags": [], + "minerals_tags": [], + "misc_tags": [ + "en:nutrition-fruits-vegetables-nuts-estimate-from-ingredients", + "en:nutrition-all-nutriscore-values-known", + "en:nutriscore-computed", + "en:ecoscore-extended-data-computed", + "en:ecoscore-extended-data-version-4", + "en:ecoscore-missing-data-warning", + "en:ecoscore-missing-data-labels", + "en:ecoscore-missing-data-origins", + "en:ecoscore-computed" + ], + "no_nutrition_data": "", + "nova_group": 4, + "nova_group_debug": "", + "nova_groups": "4", + "nova_groups_markers": { + "3": [ + [ + "categories", + "en:salty-snacks" + ], + [ + "ingredients", + "en:salt" + ], + [ + "ingredients", + "en:starch" + ], + [ + "ingredients", + "en:sugar" + ], + [ + "ingredients", + "en:vegetable-oil" + ] + ], + "4": [ + [ + "additives", + "en:e160c" + ], + [ + "ingredients", + "en:colour" + ], + [ + "ingredients", + "en:flavouring" + ], + [ + "ingredients", + "en:whey" + ] + ] + }, + "nova_groups_tags": [ + "en:4-ultra-processed-food-and-drink-products" + ], + "nucleotides_prev_tags": [], + "nucleotides_tags": [], + "nutrient_levels": { + "fat": "moderate", + "salt": "high", + "saturated-fat": "low", + "sugars": "moderate" + }, + "nutrient_levels_tags": [ + "en:fat-in-moderate-quantity", + "en:saturated-fat-in-low-quantity", + "en:sugars-in-moderate-quantity", + "en:salt-in-high-quantity" + ], + "nutriments": { + "carbohydrates": 15, + "carbohydrates_100g": 15, + "carbohydrates_serving": 3.45, + "carbohydrates_unit": "g", + "carbohydrates_value": 15, + "carbon-footprint-from-known-ingredients_100g": 32.4, + "carbon-footprint-from-known-ingredients_product": 7.45, + "carbon-footprint-from-known-ingredients_serving": 7.45, + "energy": 1757, + "energy-kcal": 420, + "energy-kcal_100g": 420, + "energy-kcal_serving": 96.6, + "energy-kcal_unit": "kcal", + "energy-kcal_value": 420, + "energy_100g": 1757, + "energy_serving": 404, + "energy_unit": "kcal", + "energy_value": 420, + "fat": 15, + "fat_100g": 15, + "fat_serving": 3.45, + "fat_unit": "g", + "fat_value": 15, + "fiber": 3.9, + "fiber_100g": 3.9, + "fiber_serving": 0.897, + "fiber_unit": "g", + "fiber_value": 3.9, + "fruits-vegetables-nuts-estimate-from-ingredients_100g": 0, + "fruits-vegetables-nuts-estimate-from-ingredients_serving": 0, + "nova-group": 4, + "nova-group_100g": 4, + "nova-group_serving": 4, + "nutrition-score-fr": 12, + "nutrition-score-fr_100g": 12, + "proteins": 5.7, + "proteins_100g": 5.7, + "proteins_serving": 1.31, + "proteins_unit": "g", + "proteins_value": 5.7, + "salt": 2.1, + "salt_100g": 2.1, + "salt_serving": 0.483, + "salt_unit": "g", + "salt_value": 2.1, + "saturated-fat": 1.4, + "saturated-fat_100g": 1.4, + "saturated-fat_serving": 0.322, + "saturated-fat_unit": "g", + "saturated-fat_value": 1.4, + "sodium": 0.84, + "sodium_100g": 0.84, + "sodium_serving": 0.193, + "sodium_unit": "g", + "sodium_value": 0.84, + "sugars": 8.7, + "sugars_100g": 8.7, + "sugars_serving": 2, + "sugars_unit": "g", + "sugars_value": 8.7 + }, + "nutriscore_data": { + "energy": 1757, + "energy_points": 5, + "energy_value": 1757, + "fiber": 3.9, + "fiber_points": 4, + "fiber_value": 3.9, + "fruits_vegetables_nuts_colza_walnut_olive_oils": 0, + "fruits_vegetables_nuts_colza_walnut_olive_oils_points": 0, + "fruits_vegetables_nuts_colza_walnut_olive_oils_value": 0, + "grade": "d", + "is_beverage": 0, + "is_cheese": 0, + "is_fat": 0, + "is_water": 0, + "negative_points": 16, + "positive_points": 4, + "proteins": 5.7, + "proteins_points": 3, + "proteins_value": 5.7, + "saturated_fat": 1.4, + "saturated_fat_points": 1, + "saturated_fat_ratio": 9.33333333333333, + "saturated_fat_ratio_points": 0, + "saturated_fat_ratio_value": 9.3, + "saturated_fat_value": 1.4, + "score": 12, + "sodium": 840, + "sodium_points": 9, + "sodium_value": 840, + "sugars": 8.7, + "sugars_points": 1, + "sugars_value": 8.7 + }, + "nutriscore_grade": "d", + "nutriscore_score": 12, + "nutriscore_score_opposite": -12, + "nutrition_data": "on", + "nutrition_data_per": "100g", + "nutrition_data_prepared": "", + "nutrition_data_prepared_per": "100g", + "nutrition_data_prepared_per_debug_tags": [], + "nutrition_grade_fr": "d", + "nutrition_grades": "d", + "nutrition_grades_tags": [ + "d" + ], + "nutrition_score_beverage": 0, + "nutrition_score_debug": "", + "nutrition_score_warning_fruits_vegetables_nuts_estimate_from_ingredients": 1, + "nutrition_score_warning_fruits_vegetables_nuts_estimate_from_ingredients_value": 0, + "origins": "", + "origins_hierarchy": [], + "origins_lc": "en", + "origins_old": "", + "origins_tags": [], + "other_nutritional_substances_tags": [], + "packaging": "Plastic, en:mixed plastic film-packet", + "packaging_hierarchy": [ + "en:plastic", + "en:mixed plastic film-packet" + ], + "packaging_lc": "en", + "packaging_old": "Plastic, Mixed plastic-packet", + "packaging_old_before_taxonomization": "Plastic, en:mixed plastic-packet", + "packaging_tags": [ + "en:plastic", + "en:mixed-plastic-film-packet" + ], + "packagings": [ + { + "material": "en:plastic", + "shape": "en:pack" + } + ], + "photographers_tags": [ + "kyzh", + "kiliweb" + ], + "pnns_groups_1": "Salty snacks", + "pnns_groups_1_tags": [ + "salty-snacks", + "known" + ], + "pnns_groups_2": "Appetizers", + "pnns_groups_2_tags": [ + "appetizers", + "known" + ], + "popularity_key": 20900000020, + "popularity_tags": [ + "bottom-25-percent-scans-2019", + "bottom-20-percent-scans-2019", + "bottom-15-percent-scans-2019", + "top-90-percent-scans-2019", + "top-10000-gb-scans-2019", + "top-50000-gb-scans-2019", + "top-100000-gb-scans-2019", + "top-country-gb-scans-2019", + "bottom-25-percent-scans-2020", + "top-80-percent-scans-2020", + "top-85-percent-scans-2020", + "top-90-percent-scans-2020", + "top-5000-gb-scans-2020", + "top-10000-gb-scans-2020", + "top-50000-gb-scans-2020", + "top-100000-gb-scans-2020", + "top-country-gb-scans-2020", + "top-100000-scans-2021", + "at-least-5-scans-2021", + "top-75-percent-scans-2021", + "top-80-percent-scans-2021", + "top-85-percent-scans-2021", + "top-90-percent-scans-2021", + "top-5000-gb-scans-2021", + "top-10000-gb-scans-2021", + "top-50000-gb-scans-2021", + "top-100000-gb-scans-2021", + "top-country-gb-scans-2021", + "at-least-5-gb-scans-2021", + "top-5000-ie-scans-2021", + "top-10000-ie-scans-2021", + "top-50000-ie-scans-2021", + "top-100000-ie-scans-2021", + "top-1000-mu-scans-2021", + "top-5000-mu-scans-2021", + "top-10000-mu-scans-2021", + "top-50000-mu-scans-2021", + "top-100000-mu-scans-2021" + ], + "product_name": "Barbeque Potato Chips", + "product_name_en": "Barbeque Potato Chips", + "product_name_fr": "", + "product_name_fr_debug_tags": [], + "product_quantity": "23", + "purchase_places": "", + "purchase_places_debug_tags": [], + "purchase_places_tags": [], + "quantity": "23 g", + "quantity_debug_tags": [], + "removed_countries_tags": [], + "rev": 32, + "scans_n": 10, + "selected_images": { + "front": { + "display": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.400.jpg" + }, + "small": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.200.jpg" + }, + "thumb": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.100.jpg" + } + }, + "ingredients": { + "display": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.400.jpg" + }, + "small": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.200.jpg" + }, + "thumb": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.100.jpg" + } + }, + "nutrition": { + "display": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.400.jpg" + }, + "small": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.200.jpg" + }, + "thumb": { + "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.100.jpg" + } + } + }, + "serving_quantity": "23", + "serving_size": "23 g", + "serving_size_debug_tags": [], + "sortkey": 1535456524, + "states": "en:to-be-completed, en:nutrition-facts-completed, en:ingredients-completed, en:expiration-date-completed, en:packaging-code-to-be-completed, en:characteristics-to-be-completed, en:origins-to-be-completed, en:categories-completed, en:brands-completed, en:packaging-completed, en:quantity-completed, en:product-name-completed, en:photos-to-be-validated, en:packaging-photo-to-be-selected, en:nutrition-photo-selected, en:ingredients-photo-selected, en:front-photo-selected, en:photos-uploaded", + "states_hierarchy": [ + "en:to-be-completed", + "en:nutrition-facts-completed", + "en:ingredients-completed", + "en:expiration-date-completed", + "en:packaging-code-to-be-completed", + "en:characteristics-to-be-completed", + "en:origins-to-be-completed", + "en:categories-completed", + "en:brands-completed", + "en:packaging-completed", + "en:quantity-completed", + "en:product-name-completed", + "en:photos-to-be-validated", + "en:packaging-photo-to-be-selected", + "en:nutrition-photo-selected", + "en:ingredients-photo-selected", + "en:front-photo-selected", + "en:photos-uploaded" + ], + "states_tags": [ + "en:to-be-completed", + "en:nutrition-facts-completed", + "en:ingredients-completed", + "en:expiration-date-completed", + "en:packaging-code-to-be-completed", + "en:characteristics-to-be-completed", + "en:origins-to-be-completed", + "en:categories-completed", + "en:brands-completed", + "en:packaging-completed", + "en:quantity-completed", + "en:product-name-completed", + "en:photos-to-be-validated", + "en:packaging-photo-to-be-selected", + "en:nutrition-photo-selected", + "en:ingredients-photo-selected", + "en:front-photo-selected", + "en:photos-uploaded" + ], + "stores": "", + "stores_debug_tags": [], + "stores_tags": [], + "teams": "swipe-studio", + "teams_tags": [ + "swipe-studio" + ], + "traces": "", + "traces_debug_tags": [], + "traces_from_ingredients": "", + "traces_from_user": "(en) ", + "traces_hierarchy": [], + "traces_tags": [], + "unique_scans_n": 8, + "unknown_ingredients_n": 1, + "unknown_nutrients_tags": [], + "update_key": "update20221107", + "vitamins_prev_tags": [], + "vitamins_tags": [] + }, + "status": 1, + "status_verbose": "product found" +} diff --git a/examples/tree.py b/examples/tree.py new file mode 100644 index 000000000..756fb2362 --- /dev/null +++ b/examples/tree.py @@ -0,0 +1,32 @@ +import json + +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, Tree + + +with open("food.json") as data_file: + data = json.load(data_file) + +from rich import print + +print(data) + + +class TreeApp(App): + + BINDINGS = [("a", "add", "Add node")] + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Tree("Root") + + def action_add(self) -> None: + tree = self.query_one(Tree) + + tree.add_json(data) + + +if __name__ == "__main__": + app = TreeApp() + app.run() diff --git a/src/textual/widget.py b/src/textual/widget.py index fad5ed4d8..2281c90d5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -857,6 +857,18 @@ class Widget(DOMNode): content_region = self.region.shrink(self.styles.gutter) return content_region + @property + def scrollable_content_region(self) -> Region: + """Gets an absolute region containing the scrollable content (minus padding, border, and scrollbars). + + Returns: + Region: Screen region that contains a widget's content. + """ + content_region = self.region.shrink(self.styles.gutter).shrink( + self.scrollbar_gutter + ) + return content_region + @property def content_offset(self) -> Offset: """An offset from the Widget origin where the content begins. @@ -1622,7 +1634,7 @@ class Widget(DOMNode): Returns: Offset: The distance that was scrolled. """ - window = self.content_region.at_offset(self.scroll_offset) + window = self.scrollable_content_region.at_offset(self.scroll_offset) if spacing is not None: window = window.shrink(spacing) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 2d42a7d3c..d4deac65a 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -1,8 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from operator import attrgetter -from typing import ClassVar, Generic, NewType, TypeVar +from typing import Callable, ClassVar, Generic, NewType, TypeVar import rich.repr from rich.segment import Segment @@ -51,25 +50,31 @@ class TreeNode(Generic[TreeDataType]): parent: TreeNode[TreeDataType] | None, id: NodeID, label: Text, - data: TreeDataType, + data: TreeDataType | None = None, *, expanded: bool = True, + allow_expand: bool = True, ) -> None: self._tree = tree self._parent = parent self.id = id self.label = label - self.data: TreeDataType = data + self.data: TreeDataType = data if data is not None else tree._data_factory() self._expanded = expanded self.children: list[TreeNode] = [] self._hover = False self._selected = False + self._allow_expand = allow_expand def __rich_repr__(self) -> rich.repr.Result: yield self.label.plain yield self.data + def _reset(self) -> None: + self._hover = False + self._selected = False + @property def expanded(self) -> bool: return self._expanded @@ -93,7 +98,12 @@ class TreeNode(Generic[TreeDataType]): ) def add( - self, label: TextType, data: TreeDataType, expanded: bool = True + self, + label: TextType, + data: TreeDataType | None = None, + *, + expanded: bool = True, + allow_expand: bool = True, ) -> TreeNode[TreeDataType]: """Add a node to the sub-tree. @@ -111,6 +121,7 @@ class TreeNode(Generic[TreeDataType]): text_label = label node = self._tree._add_node(self, text_label, data) node._expanded = expanded + node._allow_expand = allow_expand self.children.append(node) self._tree.invalidate() return node @@ -133,7 +144,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): } Tree > .tree--guides { - color: $success; + color: $success-darken-3; } Tree > .tree--guides-hover { @@ -162,8 +173,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """ - show_root = reactive(True) - COMPONENT_CLASSES: ClassVar[set[str]] = { "tree--label", "tree--guides", @@ -174,9 +183,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): "tree--highlight-line", } + show_root = reactive(True) hover_line = var(-1) cursor_line = var(-1) - guide_depth = var(4, init=False) + show_guides = reactive(True) + guide_depth = reactive(4, init=False) auto_expand = var(True) LINES: dict[str, tuple[str, str, str, str]] = { @@ -210,18 +221,18 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def __init__( self, label: TextType, - data: TreeDataType, + data: TreeDataType | None = None, + data_factory: Callable[[], TreeDataType] = dict, *, name: str | None = None, id: str | None = None, classes: str | None = None, ) -> None: super().__init__(name=name, id=id, classes=classes) - if isinstance(label, str): - text_label = Text.from_markup(label) - else: - text_label = label + text_label = self.process_label(label) + + self._data_factory = data_factory self._updates = 0 self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._current_id = 0 @@ -230,15 +241,38 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None + @classmethod + def process_label(cls, label: TextType): + """Process a str or Text in to a label. + + Args: + label (TextType): Label. + + Returns: + Text: A Rich Text object. + """ + if isinstance(label, str): + text_label = Text.from_markup(label) + else: + text_label = label + first_line = text_label.split()[0] + return first_line + def _add_node( - self, parent: TreeNode[TreeDataType] | None, label: Text, data: TreeDataType + self, + parent: TreeNode[TreeDataType] | None, + label: Text, + data: TreeDataType | None, ) -> TreeNode[TreeDataType]: - node = TreeNode(self, parent, self._new_id(), label, data) + node_data = data if data is not None else self._data_factory() + node = TreeNode(self, parent, self._new_id(), label, node_data) self._nodes[node.id] = node self._updates += 1 return node - def render_label(self, node: TreeNode[TreeDataType]) -> Text: + def render_label( + self, node: TreeNode[TreeDataType], base_style: Style, style: Style + ) -> Text: """Render a label for the given node. Override this to modify how labels are rendered. Args: @@ -247,7 +281,16 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): Returns: Text: A Rich Text object containing the label. """ - return node.label + node_label = node.label.copy() + node_label.stylize(style) + + if node._allow_expand: + prefix = ("▼ " if node.expanded else "▶ ", base_style) + else: + prefix = ("", base_style) + + text = Text.assemble(prefix, node_label) + return text def clear(self) -> None: """Clear all nodes under root.""" @@ -266,6 +309,36 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._updates += 1 self.refresh() + def add_json(self, json_data: object) -> None: + + from rich.highlighter import ReprHighlighter + + highlighter = ReprHighlighter() + + def add_node(name: str, node: TreeNode, data: object) -> None: + if isinstance(data, dict): + node.label = Text(f"{{}} {name}") + for key, value in data.items(): + new_node = node.add("") + add_node(key, new_node, value) + elif isinstance(data, list): + node.label = Text(f"[] {name}") + for index, value in enumerate(data): + new_node = node.add("") + add_node(str(index), new_node, value) + else: + node._allow_expand = False + if name: + label = Text.assemble( + Text.from_markup(f"[b]{name}[/b]="), highlighter(repr(data)) + ) + else: + label = Text(repr(data)) + node.label = label + + add_node("", self.root, json_data) + self.invalidate() + def validate_cursor_line(self, value: int) -> int: return clamp(value, 0, len(self._tree_lines) - 1) @@ -273,8 +346,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): return clamp(value, 2, 10) def invalidate(self) -> None: + self._line_cache.clear() self._tree_lines_cached = None self._updates += 1 + self.root._reset() self.refresh() def _on_mouse_move(self, event: events.MouseMove): @@ -328,6 +403,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def watch_guide_depth(self, guide_depth: int) -> None: self.invalidate() + def watch_show_root(self, show_root: bool) -> None: + self.invalidate() + def scroll_to_line(self, line: int) -> None: self.scroll_to_region(Region(0, line, self.size.width, 1)) @@ -374,7 +452,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): for last, child in loop_last(node.children): add_node(child_path, child, last=last) - add_node([], root, True) + if self.show_root: + add_node([], root, True) + else: + for node in self.root.children: + add_node([], node, True) self._tree_lines_cached = lines guide_depth = self.guide_depth @@ -383,11 +465,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.virtual_size = Size(width, len(lines)) def render_line(self, y: int) -> list[Segment]: - width, height = self.size + width = self.size.width scroll_x, scroll_y = self.scroll_offset - y += scroll_y style = self.rich_style - return self._render_line(y, scroll_x, scroll_x + width, style) + return self._render_line( + y + scroll_y, + scroll_x, + scroll_x + width, + style, + ) def _render_line( self, y: int, x1: int, x2: int, base_style: Style @@ -404,96 +490,106 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): cache_key = ( y, + is_hover, width, self._updates, y == self.hover_line, y == self.cursor_line, self.has_focus, - tuple(node._hover for node in line.path), - tuple(node._selected for node in line.path), + tuple((node._hover, node._selected, node.expanded) for node in line.path), ) if cache_key in self._line_cache: - return self._line_cache[cache_key] - - base_guide_style = self.get_component_rich_style("tree--guides", partial=True) - guide_hover_style = base_guide_style + self.get_component_rich_style( - "tree--guides-hover", partial=True - ) - guide_selected_style = base_guide_style + self.get_component_rich_style( - "tree--guides-selected", partial=True - ) - - hover = self.root._hover - selected = self.root._selected and self.has_focus - - def get_guides(style: Style) -> tuple[str, str, str, str]: - """Get the guide strings for a given style. - - Args: - style (Style): A Style object. - - Returns: - tuple[str, str, str, str]: Strings for space, vertical, terminator and cross. - """ - lines = self.LINES["default"] - if style.bold: - lines = self.LINES["bold"] - elif style.underline2: - lines = self.LINES["double"] - - guide_depth = max(0, self.guide_depth - 2) - lines = tuple( - f"{vertical}{horizontal * guide_depth} " - for vertical, horizontal in lines - ) - return lines - - if is_hover: - line_style = self.get_component_rich_style("tree--highlight-line") + segments = self._line_cache[cache_key] else: - line_style = base_style - - guides = Text(style=line_style) - guides_append = guides.append - - guide_style = base_guide_style - for node in line.path[1:]: - if hover: - guide_style = guide_hover_style - if selected: - guide_style = guide_selected_style - - space, vertical, _, _ = get_guides(guide_style) - guide = space if node.last else vertical - if node != line.path[-1]: - guides_append(guide, style=guide_style) - hover = hover or node._hover - selected = (selected or node._selected) and self.has_focus - - if len(line.path) > 1: - _, _, terminator, cross = get_guides(guide_style) - if line.last: - guides.append(terminator, style=guide_style) - else: - guides.append(cross, style=guide_style) - - label = self.render_label(line.path[-1]).copy() - label.stylize(self.get_component_rich_style("tree--label", partial=True)) - if self.hover_line == y: - label.stylize( - self.get_component_rich_style("tree--highlight", partial=True) + base_guide_style = self.get_component_rich_style( + "tree--guides", partial=True + ) + guide_hover_style = base_guide_style + self.get_component_rich_style( + "tree--guides-hover", partial=True + ) + guide_selected_style = base_guide_style + self.get_component_rich_style( + "tree--guides-selected", partial=True ) - if self.cursor_line == y and self.has_focus: - label.stylize(self.get_component_rich_style("tree--cursor", partial=False)) - label.stylize(Style(meta={"node": line.node.id, "line": y})) - guides.append(label) + hover = self.root._hover + selected = self.root._selected and self.has_focus + + def get_guides(style: Style) -> tuple[str, str, str, str]: + """Get the guide strings for a given style. + + Args: + style (Style): A Style object. + + Returns: + tuple[str, str, str, str]: Strings for space, vertical, terminator and cross. + """ + if self.show_guides: + lines = self.LINES["default"] + if style.bold: + lines = self.LINES["bold"] + elif style.underline2: + lines = self.LINES["double"] + else: + lines = (" ", " ", " ", " ") + + guide_depth = max(0, self.guide_depth - 2) + lines = tuple( + f"{vertical}{horizontal * guide_depth} " + for vertical, horizontal in lines + ) + return lines + + if is_hover: + line_style = self.get_component_rich_style("tree--highlight-line") + else: + line_style = base_style + + guides = Text(style=line_style) + guides_append = guides.append + + guide_style = base_guide_style + for node in line.path[1:]: + if hover: + guide_style = guide_hover_style + if selected: + guide_style = guide_selected_style + + space, vertical, _, _ = get_guides(guide_style) + guide = space if node.last else vertical + if node != line.path[-1]: + guides_append(guide, style=guide_style) + hover = hover or node._hover + selected = (selected or node._selected) and self.has_focus + + if len(line.path) > 1: + _, _, terminator, cross = get_guides(guide_style) + if line.last: + guides.append(terminator, style=guide_style) + else: + guides.append(cross, style=guide_style) + + label_style = self.get_component_rich_style("tree--label", partial=True) + if self.hover_line == y: + label_style += self.get_component_rich_style( + "tree--highlight", partial=True + ) + if self.cursor_line == y and self.has_focus: + label_style += self.get_component_rich_style( + "tree--cursor", partial=False + ) + + label = self.render_label(line.path[-1], line_style, label_style).copy() + label.stylize(Style(meta={"node": line.node.id, "line": y})) + guides.append(label) + + segments = list(guides.render(self.app.console)) + segments = line_pad( + segments, 0, self.virtual_size.width - guides.cell_len, line_style + ) + self._line_cache[cache_key] = segments - segments = list(guides.render(self.app.console)) - segments = line_pad(segments, 0, width - guides.cell_len, line_style) segments = line_crop(segments, x1, x2, width) - self._line_cache[cache_key] = segments return segments def _on_resize(self) -> None: From dfd7b6c8d9846975ad6ec4ec36a924f39d205286 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 17:09:47 +0000 Subject: [PATCH 14/33] added API to nodes --- examples/tree.py | 18 +++++- src/textual/widgets/_tree.py | 109 ++++++++++++++++++++++++----------- 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/examples/tree.py b/examples/tree.py index 756fb2362..3662d78f7 100644 --- a/examples/tree.py +++ b/examples/tree.py @@ -14,7 +14,11 @@ print(data) class TreeApp(App): - BINDINGS = [("a", "add", "Add node")] + BINDINGS = [ + ("a", "add", "Add node"), + ("c", "clear", "Clear"), + ("t", "toggle_root", "Toggle root"), + ] def compose(self) -> ComposeResult: yield Header() @@ -24,7 +28,17 @@ class TreeApp(App): def action_add(self) -> None: tree = self.query_one(Tree) - tree.add_json(data) + json_node = tree.root.add("JSON") + tree.root.expand() + tree.add_json(json_node, data) + + def action_clear(self) -> None: + tree = self.query_one(Tree) + tree.clear() + + def action_toggle_root(self) -> None: + tree = self.query_one(Tree) + tree.show_root = not tree.show_root if __name__ == "__main__": diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index d4deac65a..52e460acb 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -39,7 +39,7 @@ class _TreeLine: return self.path[-1] def get_line_width(self, guide_depth: int) -> int: - return (len(self.path)) + self.path[-1].label.cell_len - guide_depth + return (len(self.path)) + self.path[-1]._label.cell_len - guide_depth @rich.repr.auto @@ -58,7 +58,7 @@ class TreeNode(Generic[TreeDataType]): self._tree = tree self._parent = parent self.id = id - self.label = label + self._label = label self.data: TreeDataType = data if data is not None else tree._data_factory() self._expanded = expanded self.children: list[TreeNode] = [] @@ -68,22 +68,32 @@ class TreeNode(Generic[TreeDataType]): self._allow_expand = allow_expand def __rich_repr__(self) -> rich.repr.Result: - yield self.label.plain + yield self._label.plain yield self.data def _reset(self) -> None: self._hover = False self._selected = False - @property - def expanded(self) -> bool: - return self._expanded - - @expanded.setter - def expanded(self, expanded: bool) -> None: - self._expanded = expanded + def expand(self) -> None: + """Expand a node (show its children).""" + self._expanded = True self._tree.invalidate() + def collapse(self) -> None: + """Collapse the node (hide children).""" + self._expanded = False + self._tree.invalidate() + + def toggle(self) -> None: + self._expanded = not self._expanded + self._tree.invalidate() + + @property + def is_expanded(self) -> bool: + """Check if the node is expanded.""" + return self._expanded + @property def last(self) -> bool: """Check if this is the last child. @@ -102,7 +112,7 @@ class TreeNode(Generic[TreeDataType]): label: TextType, data: TreeDataType | None = None, *, - expanded: bool = True, + expand: bool = False, allow_expand: bool = True, ) -> TreeNode[TreeDataType]: """Add a node to the sub-tree. @@ -120,7 +130,7 @@ class TreeNode(Generic[TreeDataType]): else: text_label = label node = self._tree._add_node(self, text_label, data) - node._expanded = expanded + node._expanded = expand node._allow_expand = allow_expand self.children.append(node) self._tree.invalidate() @@ -237,7 +247,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._current_id = 0 self.root = self._add_node(None, text_label, data) - self.root._expanded = True + self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None @@ -263,9 +273,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): parent: TreeNode[TreeDataType] | None, label: Text, data: TreeDataType | None, + expand: bool = False, ) -> TreeNode[TreeDataType]: node_data = data if data is not None else self._data_factory() - node = TreeNode(self, parent, self._new_id(), label, node_data) + node = TreeNode(self, parent, self._new_id(), label, node_data, expanded=expand) self._nodes[node.id] = node self._updates += 1 return node @@ -281,11 +292,14 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): Returns: Text: A Rich Text object containing the label. """ - node_label = node.label.copy() + node_label = node._label.copy() node_label.stylize(style) if node._allow_expand: - prefix = ("▼ " if node.expanded else "▶ ", base_style) + prefix = ( + "▼ " if node.is_expanded else "▶ ", + base_style + Style.from_meta({"toggle": True}), + ) else: prefix = ("", base_style) @@ -296,7 +310,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """Clear all nodes under root.""" self._tree_lines_cached = None self._current_id = 0 - root_label = self.root.label + root_label = self.root._label root_data = self.root.data self.root = TreeNode( self, @@ -309,7 +323,23 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._updates += 1 self.refresh() - def add_json(self, json_data: object) -> None: + def get_node_at_line(self, line_no: int) -> TreeNode[TreeDataType] | None: + """Get the node for a given line. + + Args: + line_no (int): A line number. + + Returns: + TreeNode[TreeDataType] | None: A tree node, or ``None`` if there is no node at that line. + """ + try: + line = self._tree_lines[line_no] + except IndexError: + return None + else: + return line.node + + def add_json(self, node: TreeNode, json_data: object) -> None: from rich.highlighter import ReprHighlighter @@ -317,12 +347,12 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def add_node(name: str, node: TreeNode, data: object) -> None: if isinstance(data, dict): - node.label = Text(f"{{}} {name}") + node._label = Text(f"{{}} {name}") for key, value in data.items(): new_node = node.add("") add_node(key, new_node, value) elif isinstance(data, list): - node.label = Text(f"[] {name}") + node._label = Text(f"[] {name}") for index, value in enumerate(data): new_node = node.add("") add_node(str(index), new_node, value) @@ -334,10 +364,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): ) else: label = Text(repr(data)) - node.label = label + node._label = label - add_node("", self.root, json_data) - self.invalidate() + add_node(node._label, node, json_data) + # self.invalidate() def validate_cursor_line(self, value: int) -> int: return clamp(value, 0, len(self._tree_lines) - 1) @@ -350,7 +380,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._tree_lines_cached = None self._updates += 1 self.root._reset() - self.refresh() + self.refresh(layout=True) def _on_mouse_move(self, event: events.MouseMove): meta = event.style.meta @@ -460,9 +490,14 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._tree_lines_cached = lines guide_depth = self.guide_depth - width = max([line.get_line_width(guide_depth) for line in lines]) + if lines: + width = max([line.get_line_width(guide_depth) for line in lines]) + else: + width = self.size.width self.virtual_size = Size(width, len(lines)) + if self.cursor_line >= len(lines): + self.cursor_line = -1 def render_line(self, y: int) -> list[Segment]: width = self.size.width @@ -496,7 +531,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): y == self.hover_line, y == self.cursor_line, self.has_focus, - tuple((node._hover, node._selected, node.expanded) for node in line.path), + tuple((node._hover, node._selected, node._expanded) for node in line.path), ) if cache_key in self._line_cache: segments = self._line_cache[cache_key] @@ -583,27 +618,31 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): guides.append(label) segments = list(guides.render(self.app.console)) - segments = line_pad( - segments, 0, self.virtual_size.width - guides.cell_len, line_style - ) + pad_width = max(self.virtual_size.width, width) + segments = line_pad(segments, 0, pad_width - guides.cell_len, line_style) self._line_cache[cache_key] = segments segments = line_crop(segments, x1, x2, width) return segments - def _on_resize(self) -> None: + def _on_resize(self, event: events.Resize) -> None: + self._line_cache.grow(event.size.height) self.invalidate() - self._line_cache.grow(self.size.height * 2) async def _on_click(self, event: events.Click) -> None: meta = event.style.meta if "line" in meta: cursor_line = meta["line"] - if self.cursor_line == cursor_line: - await self.action("select_cursor") + if meta.get("toggle", False): + node = self.get_node_at_line(cursor_line) + if node is not None: + node.toggle() else: - self.cursor_line = cursor_line + if self.cursor_line == cursor_line: + await self.action("select_cursor") + else: + self.cursor_line = cursor_line def action_cursor_up(self) -> None: if self.cursor_line == -1: @@ -622,5 +661,5 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): else: node = line.path[-1] if self.auto_expand: - node.expanded = not node.expanded + node.toggle() self.emit_no_wait(self.NodeSelected(self, line.path[-1])) From 926c5593bc3e5d527e92eabba13d556d7825acaa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 17:42:36 +0000 Subject: [PATCH 15/33] better caching and node API --- src/textual/widgets/_tree.py | 100 ++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 52e460acb..24e2f617d 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -57,15 +57,16 @@ class TreeNode(Generic[TreeDataType]): ) -> None: self._tree = tree self._parent = parent - self.id = id + self._id = id self._label = label self.data: TreeDataType = data if data is not None else tree._data_factory() self._expanded = expanded - self.children: list[TreeNode] = [] + self._children: list[TreeNode] = [] self._hover = False self._selected = False self._allow_expand = allow_expand + self._updates: int = 0 def __rich_repr__(self) -> rich.repr.Result: yield self._label.plain @@ -74,20 +75,59 @@ class TreeNode(Generic[TreeDataType]): def _reset(self) -> None: self._hover = False self._selected = False + self._updates += 1 + + @property + def hover(self) -> bool: + return self._hover + + @hover.setter + def hover(self, hover: bool) -> None: + self._updates += 1 + self._hover = hover + + @property + def selected(self) -> bool: + return self._selected + + @hover.setter + def selected(self, selected: bool) -> None: + self._updates += 1 + self._selected = selected + + @property + def id(self) -> NodeID: + """Get the node ID.""" + return self._id def expand(self) -> None: """Expand a node (show its children).""" self._expanded = True - self._tree.invalidate() + self._updates += 1 + self._tree._invalidate() def collapse(self) -> None: """Collapse the node (hide children).""" self._expanded = False - self._tree.invalidate() + self._updates += 1 + self._tree._invalidate() def toggle(self) -> None: + """Toggle the expanded state.""" self._expanded = not self._expanded - self._tree.invalidate() + self._updates += 1 + self._tree._invalidate() + + def set_label(self, label: TextType) -> None: + """Set a new label for the node. + + Args: + label (TextType): A str or Text object with the new label. + """ + self._updates += 1 + text_label = self._tree.process_label(label) + self._label = text_label + self._tree._invalidate() @property def is_expanded(self) -> bool: @@ -104,7 +144,7 @@ class TreeNode(Generic[TreeDataType]): if self._parent is None: return True return bool( - self._parent.children and self._parent.children[-1] == self, + self._parent._children and self._parent._children[-1] == self, ) def add( @@ -132,8 +172,9 @@ class TreeNode(Generic[TreeDataType]): node = self._tree._add_node(self, text_label, data) node._expanded = expand node._allow_expand = allow_expand - self.children.append(node) - self._tree.invalidate() + self._updates += 1 + self._children.append(node) + self._tree._invalidate() return node @@ -253,7 +294,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): @classmethod def process_label(cls, label: TextType): - """Process a str or Text in to a label. + """Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered. Args: label (TextType): Label. @@ -277,7 +318,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): ) -> TreeNode[TreeDataType]: node_data = data if data is not None else self._data_factory() node = TreeNode(self, parent, self._new_id(), label, node_data, expanded=expand) - self._nodes[node.id] = node + self._nodes[node._id] = node self._updates += 1 return node @@ -366,8 +407,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): label = Text(repr(data)) node._label = label - add_node(node._label, node, json_data) - # self.invalidate() + add_node("JSON", node, json_data) + self._invalidate() def validate_cursor_line(self, value: int) -> int: return clamp(value, 0, len(self._tree_lines) - 1) @@ -375,7 +416,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def validate_guide_depth(self, value: int) -> int: return clamp(value, 2, 10) - def invalidate(self) -> None: + def _invalidate(self) -> None: + """Invalidate caches.""" self._line_cache.clear() self._tree_lines_cached = None self._updates += 1 @@ -411,35 +453,45 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): previous_node = self._get_node(previous_hover_line) if previous_node is not None: self._refresh_node(previous_node) - previous_node._hover = False + previous_node.hover = False node = self._get_node(hover_line) if node is not None: self._refresh_node(node) - node._hover = True + node.hover = True def watch_cursor_line(self, previous_line: int, line: int) -> None: previous_node = self._get_node(previous_line) if previous_node is not None: self._refresh_node(previous_node) - previous_node._selected = False + previous_node.selected = False node = self._get_node(line) if node is not None: self._refresh_node(node) self.scroll_to_line(line) - node._selected = True + node.selected = True def watch_guide_depth(self, guide_depth: int) -> None: - self.invalidate() + self._invalidate() def watch_show_root(self, show_root: bool) -> None: - self.invalidate() + self._invalidate() def scroll_to_line(self, line: int) -> None: + """Scroll to the given line. + + Args: + line (int): A line number. + """ self.scroll_to_region(Region(0, line, self.size.width, 1)) def refresh_line(self, line: int) -> None: + """Refresh (repaint) a given line in the tree. + + Args: + line (int): Line number. + """ region = Region(0, line - self.scroll_offset.y, self.size.width, 1) self.refresh(region) @@ -479,13 +531,13 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): child_path = [*path, node] add_line(_TreeLine(child_path, last)) if node._expanded: - for last, child in loop_last(node.children): + for last, child in loop_last(node._children): add_node(child_path, child, last=last) if self.show_root: add_node([], root, True) else: - for node in self.root.children: + for node in self.root._children: add_node([], node, True) self._tree_lines_cached = lines @@ -531,7 +583,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): y == self.hover_line, y == self.cursor_line, self.has_focus, - tuple((node._hover, node._selected, node._expanded) for node in line.path), + tuple(node._updates for node in line.path), ) if cache_key in self._line_cache: segments = self._line_cache[cache_key] @@ -614,7 +666,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): ) label = self.render_label(line.path[-1], line_style, label_style).copy() - label.stylize(Style(meta={"node": line.node.id, "line": y})) + label.stylize(Style(meta={"node": line.node._id, "line": y})) guides.append(label) segments = list(guides.render(self.app.console)) @@ -628,7 +680,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def _on_resize(self, event: events.Resize) -> None: self._line_cache.grow(event.size.height) - self.invalidate() + self._invalidate() async def _on_click(self, event: events.Click) -> None: meta = event.style.meta From bd2197ee80160cf6578e0325c3284c4a771aee47 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 17:48:06 +0000 Subject: [PATCH 16/33] docstrings refinements --- src/textual/widgets/_tree.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 24e2f617d..bf1167d35 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -28,6 +28,8 @@ EventTreeDataType = TypeVar("EventTreeDataType") LineCacheKey: TypeAlias = tuple[int | tuple[int, ...], ...] +_TOGGLE_STYLE = Style.from_meta({"toggle": True}) + @dataclass class _TreeLine: @@ -36,9 +38,18 @@ class _TreeLine: @property def node(self) -> TreeNode: + """The node associated with this line.""" return self.path[-1] - def get_line_width(self, guide_depth: int) -> int: + def _get_line_width(self, guide_depth: int) -> int: + """Get the cell width of the line as rendered. + + Args: + guide_depth (int): The guide depth (cells in the indentation). + + Returns: + int: Width in cells. + """ return (len(self.path)) + self.path[-1]._label.cell_len - guide_depth @@ -135,7 +146,7 @@ class TreeNode(Generic[TreeDataType]): return self._expanded @property - def last(self) -> bool: + def is_last(self) -> bool: """Check if this is the last child. Returns: @@ -160,7 +171,8 @@ class TreeNode(Generic[TreeDataType]): Args: label (TextType): The new node's label. data (TreeDataType): Data associated with the new node. - expanded (bool, optional): Node should be expanded. Defaults to True. + expand (bool, optional): Node should be expanded. Defaults to True. + allow_expand (bool, optional): Allow use to expand the node via keyboard or mouse. Defaults to True. Returns: TreeNode[TreeDataType]: A new Tree node @@ -339,7 +351,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): if node._allow_expand: prefix = ( "▼ " if node.is_expanded else "▶ ", - base_style + Style.from_meta({"toggle": True}), + base_style + _TOGGLE_STYLE, ) else: prefix = ("", base_style) @@ -543,7 +555,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): guide_depth = self.guide_depth if lines: - width = max([line.get_line_width(guide_depth) for line in lines]) + width = max([line._get_line_width(guide_depth) for line in lines]) else: width = self.size.width @@ -642,7 +654,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): guide_style = guide_selected_style space, vertical, _, _ = get_guides(guide_style) - guide = space if node.last else vertical + guide = space if node.is_last else vertical if node != line.path[-1]: guides_append(guide, style=guide_style) hover = hover or node._hover From 8485ec61222000c5d09e8637f3ef622876431566 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 17:54:51 +0000 Subject: [PATCH 17/33] simplify cache key --- src/textual/widgets/_tree.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index bf1167d35..a09b4fb8b 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -592,8 +592,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): is_hover, width, self._updates, - y == self.hover_line, - y == self.cursor_line, self.has_focus, tuple(node._updates for node in line.path), ) From 41e4d0328b423431f8f45b57df976e749de10bdb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 18:01:26 +0000 Subject: [PATCH 18/33] reset cursor --- src/textual/widgets/_tree.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index a09b4fb8b..cd44eef82 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -488,6 +488,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._invalidate() def watch_show_root(self, show_root: bool) -> None: + self.cursor_line = -1 self._invalidate() def scroll_to_line(self, line: int) -> None: From e2f6e2f82dfb55efe64769876324dde43e849266 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Nov 2022 11:57:25 +0000 Subject: [PATCH 19/33] added directory tree --- examples/tree.py | 4 +- src/textual/widgets/_directory_tree.py | 182 +++++++++++++------------ src/textual/widgets/_tree.py | 97 +++++++++++-- 3 files changed, 181 insertions(+), 102 deletions(-) diff --git a/examples/tree.py b/examples/tree.py index 3662d78f7..688a505d7 100644 --- a/examples/tree.py +++ b/examples/tree.py @@ -1,7 +1,7 @@ import json from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, Tree +from textual.widgets import Header, Footer, Tree, DirectoryTree with open("food.json") as data_file: @@ -23,7 +23,7 @@ class TreeApp(App): def compose(self) -> ComposeResult: yield Header() yield Footer() - yield Tree("Root") + yield DirectoryTree("../") def action_add(self) -> None: tree = self.query_one(Tree) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 4961db81d..b07bb47be 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -1,32 +1,55 @@ from __future__ import annotations from dataclasses import dataclass -from functools import lru_cache -from os import scandir -import os.path - -from rich.console import RenderableType -import rich.repr +from pathlib import Path +from typing import ClassVar +from rich.style import Style from rich.text import Text -from ..message import Message -from .._types import MessageTarget -from ._tree_control import TreeControl, TreeNode +from ._tree import Tree, TreeNode, TOGGLE_STYLE @dataclass class DirEntry: path: str is_dir: bool + loaded: bool = False -class DirectoryTree(TreeControl[DirEntry]): - @rich.repr.auto - class FileClick(Message, bubble=True): - def __init__(self, sender: MessageTarget, path: str) -> None: - self.path = path - super().__init__(sender) +class DirectoryTree(Tree[DirEntry]): + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "tree--label", + "tree--guides", + "tree--guides-hover", + "tree--guides-selected", + "tree--cursor", + "tree--highlight", + "tree--highlight-line", + "directory-tree--folder", + "directory-tree--file", + "directory-tree--extension", + "directory-tree--hidden", + } + + DEFAULT_CSS = """ + DirectoryTree > .directory-tree--folder { + text-style: bold; + } + + DirectoryTree > .directory-tree--file { + + } + + DirectoryTree > .directory-tree--extension { + text-style: italic; + } + + DirectoryTree > .directory-tree--hidden { + color: $text 50%; + } + """ def __init__( self, @@ -36,84 +59,69 @@ class DirectoryTree(TreeControl[DirEntry]): id: str | None = None, classes: str | None = None, ) -> None: - self.path = os.path.expanduser(path.rstrip("/")) - label = os.path.basename(self.path) - data = DirEntry(self.path, True) - super().__init__(label, data, name=name, id=id, classes=classes) - self.root.tree.guide_style = "black" - - def render_node(self, node: TreeNode[DirEntry]) -> RenderableType: - return self.render_tree_label( - node, - node.data.is_dir, - node.expanded, - node.is_cursor, - node.id == self.hover_node, - self.has_focus, + self.path = path + super().__init__( + path, + data=DirEntry(path, True), + name=name, + id=id, + classes=classes, ) - @lru_cache(maxsize=1024 * 32) - def render_tree_label( - self, - node: TreeNode[DirEntry], - is_dir: bool, - expanded: bool, - is_cursor: bool, - is_hover: bool, - has_focus: bool, - ) -> RenderableType: - meta = { - "@click": f"click_label({node.id})", - "tree_node": node.id, - "cursor": node.is_cursor, - } - label = Text(node.label) if isinstance(node.label, str) else node.label - if is_hover: - label.stylize("underline") - if is_dir: - label.stylize("bold") - icon = "📂" if expanded else "📁" + def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style): + node_label = node._label.copy() + node_label.stylize(style) + + if node._allow_expand: + prefix = ("📂 " if node.is_expanded else "📁 ", base_style + TOGGLE_STYLE) + node_label.stylize_before( + self.get_component_rich_style("directory-tree--folder", partial=True) + ) else: - icon = "📄" - label.highlight_regex(r"\..*$", "italic") + prefix = ( + "📄 ", + base_style, + ) + node_label.stylize_before( + self.get_component_rich_style("directory-tree--file", partial=True), + ) + node_label.highlight_regex( + r"\..+$", + self.get_component_rich_style( + "directory-tree--extension", partial=True + ), + ) - if label.plain.startswith("."): - label.stylize("dim") + if node_label.plain.startswith("."): + node_label.stylize_before( + self.get_component_rich_style("directory-tree--hidden") + ) - if is_cursor and has_focus: - cursor_style = self.get_component_styles("tree--cursor").rich_style - label.stylize(cursor_style) + text = Text.assemble(prefix, node_label) + return text - icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label - icon_label.apply_meta(meta) - return icon_label - - def on_styles_updated(self) -> None: - self.render_tree_label.cache_clear() + def load_directory(self, node: TreeNode[DirEntry]) -> None: + assert node.data is not None + dir_path = Path(node.data.path) + node.data.loaded = True + directory = sorted( + list(dir_path.iterdir()), + key=lambda path: (not path.is_dir(), path.name.lower()), + ) + for path in directory: + node.add( + path.name, + data=DirEntry(str(path), path.is_dir()), + allow_expand=path.is_dir(), + ) + node.expand() def on_mount(self) -> None: - self.call_after_refresh(self.load_directory, self.root) + self.load_directory(self.root) - async def load_directory(self, node: TreeNode[DirEntry]): - path = node.data.path - directory = sorted( - list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name) - ) - for entry in directory: - node.add(entry.name, DirEntry(entry.path, entry.is_dir())) - node.loaded = True - node.expand() - self.refresh(layout=True) - - 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(self.FileClick(self, dir_entry.path)) - else: - if not message.node.loaded: - await self.load_directory(message.node) - message.node.expand() - else: - message.node.toggle() + def on_tree_node_expanded(self, event: Tree.NodeSelected) -> None: + dir_entry = event.node.data + if dir_entry is None: + return + if dir_entry.is_dir and not dir_entry.loaded: + self.load_directory(event.node) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index cd44eef82..5d48151b3 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -28,7 +28,7 @@ EventTreeDataType = TypeVar("EventTreeDataType") LineCacheKey: TypeAlias = tuple[int | tuple[int, ...], ...] -_TOGGLE_STYLE = Style.from_meta({"toggle": True}) +TOGGLE_STYLE = Style.from_meta({"toggle": True}) @dataclass @@ -70,7 +70,7 @@ class TreeNode(Generic[TreeDataType]): self._parent = parent self._id = id self._label = label - self.data: TreeDataType = data if data is not None else tree._data_factory() + self.data = data self._expanded = expanded self._children: list[TreeNode] = [] @@ -78,6 +78,7 @@ class TreeNode(Generic[TreeDataType]): self._selected = False self._allow_expand = allow_expand self._updates: int = 0 + self._line: int = -1 def __rich_repr__(self) -> rich.repr.Result: yield self._label.plain @@ -88,6 +89,10 @@ class TreeNode(Generic[TreeDataType]): self._selected = False self._updates += 1 + def line(self) -> int: + """Get the line number for this node, or -1 if it is not displayed.""" + return self._line + @property def hover(self) -> bool: return self._hover @@ -101,7 +106,7 @@ class TreeNode(Generic[TreeDataType]): def selected(self) -> bool: return self._selected - @hover.setter + @selected.setter def selected(self, selected: bool) -> None: self._updates += 1 self._selected = selected @@ -158,6 +163,10 @@ class TreeNode(Generic[TreeDataType]): self._parent._children and self._parent._children[-1] == self, ) + @property + def allow_expand(self) -> bool: + return self._allow_expand + def add( self, label: TextType, @@ -281,11 +290,24 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.node = node super().__init__(sender) + class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True): + def __init__( + self, sender: MessageTarget, node: TreeNode[EventTreeDataType] + ) -> None: + self.node = node + super().__init__(sender) + + class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True): + def __init__( + self, sender: MessageTarget, node: TreeNode[EventTreeDataType] + ) -> None: + self.node = node + super().__init__(sender) + def __init__( self, label: TextType, data: TreeDataType | None = None, - data_factory: Callable[[], TreeDataType] = dict, *, name: str | None = None, id: str | None = None, @@ -295,7 +317,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): text_label = self.process_label(label) - self._data_factory = data_factory self._updates = 0 self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._current_id = 0 @@ -303,6 +324,21 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None + self.cursor_node: TreeNode[TreeDataType] | None = None + + # @property + # def cursor_node(self) -> TreeNode[TreeDataType] | None: + # """The node under the cursor, which may be None if no cursor is visible. + + # Returns: + # TreeNode[TreeDataType] | None: A Tree node or None. + # """ + # if self.cursor_line == -1: + # return None + # try: + # self._tree_lines[self.cursor_line].path[-1] + # except IndexError: + # return None @classmethod def process_label(cls, label: TextType): @@ -328,8 +364,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): data: TreeDataType | None, expand: bool = False, ) -> TreeNode[TreeDataType]: - node_data = data if data is not None else self._data_factory() - node = TreeNode(self, parent, self._new_id(), label, node_data, expanded=expand) + node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand) self._nodes[node._id] = node self._updates += 1 return node @@ -351,7 +386,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): if node._allow_expand: prefix = ( "▼ " if node.is_expanded else "▶ ", - base_style + _TOGGLE_STYLE, + base_style + TOGGLE_STYLE, ) else: prefix = ("", base_style) @@ -376,6 +411,14 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._updates += 1 self.refresh() + def select_node(self, node: TreeNode | None) -> None: + """Move the cursor to the given node, or reset cursor. + + Args: + node (TreeNode | None): A tree node, or None to reset cursor. + """ + self.cursor_line = -1 if node is None else node._line + def get_node_at_line(self, line_no: int) -> TreeNode[TreeDataType] | None: """Get the node for a given line. @@ -477,12 +520,13 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): if previous_node is not None: self._refresh_node(previous_node) previous_node.selected = False + self.cursor_node = None node = self._get_node(line) if node is not None: self._refresh_node(node) - self.scroll_to_line(line) node.selected = True + self.cursor_node = node def watch_guide_depth(self, guide_depth: int) -> None: self._invalidate() @@ -499,6 +543,16 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """ self.scroll_to_region(Region(0, line, self.size.width, 1)) + def scroll_to_node(self, node: TreeNode) -> None: + """Scroll to the given node. + + Args: + node (TreeNode): Node to scroll in to view. + """ + line = node._line + if line != -1: + self.scroll_to_line(line) + def refresh_line(self, line: int) -> None: """Refresh (repaint) a given line in the tree. @@ -542,6 +596,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None: child_path = [*path, node] + node._line = len(lines) add_line(_TreeLine(child_path, last)) if node._expanded: for last, child in loop_last(node._children): @@ -561,6 +616,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): width = self.size.width self.virtual_size = Size(width, len(lines)) + if self.cursor_node is not None: + self.cursor_line = self.cursor_node._line if self.cursor_line >= len(lines): self.cursor_line = -1 @@ -693,28 +750,42 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._line_cache.grow(event.size.height) self._invalidate() + def _toggle_node(self, node: TreeNode[TreeDataType]) -> None: + if node.is_expanded: + node.collapse() + self.post_message_no_wait(self.NodeCollapsed(self, node)) + else: + node.expand() + self.post_message_no_wait(self.NodeExpanded(self, node)) + async def _on_click(self, event: events.Click) -> None: meta = event.style.meta if "line" in meta: cursor_line = meta["line"] if meta.get("toggle", False): node = self.get_node_at_line(cursor_line) - if node is not None: - node.toggle() + if node is not None and self.auto_expand: + self._toggle_node(node) + else: if self.cursor_line == cursor_line: await self.action("select_cursor") else: self.cursor_line = cursor_line + def _on_styles_updated(self) -> None: + self._invalidate() + def action_cursor_up(self) -> None: if self.cursor_line == -1: self.cursor_line = len(self._tree_lines) - 1 else: self.cursor_line -= 1 + self.scroll_to_line(self.cursor_line) def action_cursor_down(self) -> None: self.cursor_line += 1 + self.scroll_to_line(self.cursor_line) def action_select_cursor(self) -> None: try: @@ -724,5 +795,5 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): else: node = line.path[-1] if self.auto_expand: - node.toggle() - self.emit_no_wait(self.NodeSelected(self, line.path[-1])) + self._toggle_node(node) + self.post_message_no_wait(self.NodeSelected(self, node)) From 811dcd8eaf072629eb3d4ffd78141abc19e75af7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Nov 2022 17:21:23 +0000 Subject: [PATCH 20/33] fix stable scrollbars --- examples/code_browser.css | 17 ++-- examples/code_browser.py | 7 +- src/textual/_compositor.py | 1 + src/textual/box_model.py | 4 + src/textual/css/styles.py | 2 +- src/textual/scroll_view.py | 3 +- src/textual/widget.py | 3 +- src/textual/widgets/_data_table.py | 2 +- src/textual/widgets/_directory_tree.py | 41 +++++++- src/textual/widgets/_tree.py | 133 ++++++++++++++----------- 10 files changed, 137 insertions(+), 76 deletions(-) diff --git a/examples/code_browser.css b/examples/code_browser.css index 2a58b68e1..92b77c59b 100644 --- a/examples/code_browser.css +++ b/examples/code_browser.css @@ -3,22 +3,21 @@ Screen { } #tree-view { - display: none; + display: none; scrollbar-gutter: stable; - width: auto; + overflow: auto; + overflow-y: scroll; + width: auto; + height: 100%; + dock: left; + } CodeBrowser.-show-tree #tree-view { - display: block; - dock: left; - height: 100%; + display: block; max-width: 50%; - background: #151C25; } -DirectoryTree { - padding-right: 1; -} #code-view { overflow: auto scroll; diff --git a/examples/code_browser.py b/examples/code_browser.py index 678e8396a..215b85bcf 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -39,7 +39,7 @@ class CodeBrowser(App): path = "./" if len(sys.argv) < 2 else sys.argv[1] yield Header() yield Container( - Vertical(DirectoryTree(path), id="tree-view"), + DirectoryTree(path, id="tree-view"), Vertical(Static(id="code", expand=True), id="code-view"), ) yield Footer() @@ -47,8 +47,11 @@ class CodeBrowser(App): def on_mount(self, event: events.Mount) -> None: self.query_one(DirectoryTree).focus() - def on_directory_tree_file_click(self, event: DirectoryTree.FileClick) -> None: + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: """Called when the user click a file in the directory tree.""" + event.stop() code_view = self.query_one("#code", Static) try: syntax = Syntax.from_path( diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index ab9d69eca..6027ce819 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -458,6 +458,7 @@ class Compositor: # Add top level (root) widget add_widget(root, size.region, size.region, ((0,),), layer_order, size.region) + root.log(map) return map, widgets @property diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 8dadfc684..18ca78225 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -62,6 +62,8 @@ def get_box_model( content_width = Fraction( get_content_width(content_container - styles.margin.totals, viewport) ) + if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto": + content_width += styles.scrollbar_size_vertical else: # An explicit width styles_width = styles.width @@ -97,6 +99,8 @@ def get_box_model( content_height = Fraction( get_content_height(content_container, viewport, int(content_width)) ) + if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto": + content_height += styles.scrollbar_size_horizontal else: styles_height = styles.height # Explicit height set diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index b6fe9df6a..c2bf84364 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -556,7 +556,7 @@ class StylesBase(ABC): """Get the style properties associated with this node only (not including parents in the DOM). Returns: - Style: Rich Style object + Style: Rich Style object. """ style = Style( color=(self.color.rich_color if self.has_rule("color") else None), diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 0fc2a4cc9..e6e692480 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -75,8 +75,9 @@ class ScrollView(Widget): ): self._size = size virtual_size = self.virtual_size - self._scroll_update(virtual_size) self._container_size = size - self.styles.gutter.totals + self._scroll_update(virtual_size) + self.scroll_to(self.scroll_x, self.scroll_y, animate=False) self.refresh() diff --git a/src/textual/widget.py b/src/textual/widget.py index 71f5ebdd0..328914304 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -386,6 +386,7 @@ class Widget(DOMNode): Args: name (str): Name of component. + partial (bool, optional): Return a partial style (not combined with parent). Returns: Style: A Rich style object. @@ -915,8 +916,6 @@ class Widget(DOMNode): int: Number of rows in the horizontal scrollbar. """ styles = self.styles - if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto": - return styles.scrollbar_size_horizontal return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0 @property diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 6d5c9dd01..5d6d0a0e0 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -652,7 +652,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _scroll_cursor_in_to_view(self, animate: bool = False) -> None: region = self._get_cell_region(self.cursor_row, self.cursor_column) - spacing = self._get_cell_border() + self.scrollbar_gutter + spacing = self._get_cell_border() self.scroll_to_region(region, animate=animate, spacing=spacing) def on_click(self, event: events.Click) -> None: diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index b07bb47be..841fd30ef 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -5,9 +5,11 @@ from pathlib import Path from typing import ClassVar from rich.style import Style -from rich.text import Text +from rich.text import Text, TextType +from ..message import Message from ._tree import Tree, TreeNode, TOGGLE_STYLE +from .._types import MessageTarget @dataclass @@ -51,6 +53,11 @@ class DirectoryTree(Tree[DirEntry]): } """ + class FileSelected(Message, bubble=True): + def __init__(self, sender: MessageTarget, path: str) -> None: + self.path = path + super().__init__(sender) + def __init__( self, path: str, @@ -68,6 +75,22 @@ class DirectoryTree(Tree[DirEntry]): classes=classes, ) + def process_label(self, label: TextType): + """Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered. + + Args: + label (TextType): Label. + + Returns: + Text: A Rich Text object. + """ + if isinstance(label, str): + text_label = Text(label) + else: + text_label = label + first_line = text_label.split()[0] + return first_line + def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style): node_label = node._label.copy() node_label.stylize(style) @@ -120,8 +143,20 @@ class DirectoryTree(Tree[DirEntry]): self.load_directory(self.root) def on_tree_node_expanded(self, event: Tree.NodeSelected) -> None: + event.stop() dir_entry = event.node.data if dir_entry is None: return - if dir_entry.is_dir and not dir_entry.loaded: - self.load_directory(event.node) + if dir_entry.is_dir: + if not dir_entry.loaded: + self.load_directory(event.node) + else: + self.emit_no_wait(self.FileSelected(self, dir_entry.path)) + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + event.stop() + dir_entry = event.node.data + if dir_entry is None: + return + if not dir_entry.is_dir: + self.emit_no_wait(self.FileSelected(self, dir_entry.path)) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 5d48151b3..7fac63784 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -1,11 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Callable, ClassVar, Generic, NewType, TypeVar +from typing import ClassVar, Generic, NewType, TypeVar import rich.repr from rich.segment import Segment -from rich.style import Style +from rich.style import Style, NULL_STYLE from rich.text import Text, TextType @@ -41,7 +41,7 @@ class _TreeLine: """The node associated with this line.""" return self.path[-1] - def _get_line_width(self, guide_depth: int) -> int: + def _get_guide_width(self, guide_depth: int, show_root: bool) -> int: """Get the cell width of the line as rendered. Args: @@ -50,7 +50,8 @@ class _TreeLine: Returns: int: Width in cells. """ - return (len(self.path)) + self.path[-1]._label.cell_len - guide_depth + guides = max(0, len(self.path) - (1 if show_root else 2)) * guide_depth + return guides @rich.repr.auto @@ -74,8 +75,8 @@ class TreeNode(Generic[TreeDataType]): self._expanded = expanded self._children: list[TreeNode] = [] - self._hover = False - self._selected = False + self._hover_ = False + self._selected_ = False self._allow_expand = allow_expand self._updates: int = 0 self._line: int = -1 @@ -85,8 +86,8 @@ class TreeNode(Generic[TreeDataType]): yield self.data def _reset(self) -> None: - self._hover = False - self._selected = False + self._hover_ = False + self._selected_ = False self._updates += 1 def line(self) -> int: @@ -94,22 +95,24 @@ class TreeNode(Generic[TreeDataType]): return self._line @property - def hover(self) -> bool: - return self._hover + def _hover(self) -> bool: + """bool: Check if the mouse is over the node.""" + return self._hover_ - @hover.setter - def hover(self, hover: bool) -> None: + @_hover.setter + def _hover(self, hover: bool) -> None: self._updates += 1 - self._hover = hover + self._hover_ = hover @property - def selected(self) -> bool: - return self._selected + def _selected(self) -> bool: + """bool: Check if the node is selected.""" + return self._selected_ - @selected.setter - def selected(self, selected: bool) -> None: + @_selected.setter + def _selected(self, selected: bool) -> None: self._updates += 1 - self._selected = selected + self._selected_ = selected @property def id(self) -> NodeID: @@ -147,16 +150,12 @@ class TreeNode(Generic[TreeDataType]): @property def is_expanded(self) -> bool: - """Check if the node is expanded.""" + """bool: Check if the node is expanded.""" return self._expanded @property def is_last(self) -> bool: - """Check if this is the last child. - - Returns: - bool: True if this is the last child, otherwise False. - """ + """bool: Check if this is the last child.""" if self._parent is None: return True return bool( @@ -165,6 +164,7 @@ class TreeNode(Generic[TreeDataType]): @property def allow_expand(self) -> bool: + """bool: Check if the node is allowed to expand.""" return self._allow_expand def add( @@ -186,10 +186,7 @@ class TreeNode(Generic[TreeDataType]): Returns: TreeNode[TreeDataType]: A new Tree node """ - if isinstance(label, str): - text_label = Text.from_markup(label) - else: - text_label = label + text_label = self._tree.process_label(label) node = self._tree._add_node(self, text_label, data) node._expanded = expand node._allow_expand = allow_expand @@ -284,6 +281,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): } class NodeSelected(Generic[EventTreeDataType], Message, bubble=True): + """Event sent when a node is selected.""" + def __init__( self, sender: MessageTarget, node: TreeNode[EventTreeDataType] ) -> None: @@ -291,6 +290,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): super().__init__(sender) class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True): + """Event sent when a node is expanded.""" + def __init__( self, sender: MessageTarget, node: TreeNode[EventTreeDataType] ) -> None: @@ -298,6 +299,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): super().__init__(sender) class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True): + """Event sent when a node is collapsed.""" + def __init__( self, sender: MessageTarget, node: TreeNode[EventTreeDataType] ) -> None: @@ -324,24 +327,14 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None - self.cursor_node: TreeNode[TreeDataType] | None = None + self._cursor_node: TreeNode[TreeDataType] | None = None - # @property - # def cursor_node(self) -> TreeNode[TreeDataType] | None: - # """The node under the cursor, which may be None if no cursor is visible. + @property + def cursor_node(self) -> TreeNode[TreeDataType] | None: + """TreeNode | Node: The currently selected node, or ``None`` if no selection.""" + return self._cursor_node - # Returns: - # TreeNode[TreeDataType] | None: A Tree node or None. - # """ - # if self.cursor_line == -1: - # return None - # try: - # self._tree_lines[self.cursor_line].path[-1] - # except IndexError: - # return None - - @classmethod - def process_label(cls, label: TextType): + def process_label(self, label: TextType): """Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered. Args: @@ -394,6 +387,21 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): text = Text.assemble(prefix, node_label) return text + def get_label_width(self, node: TreeNode[TreeDataType]) -> int: + """Get the width of the nodes label. + + The default behavior is to call `render_node` and return the cell length. This method may be + overridden in a sub-class if it can be done more efficiently. + + Args: + node (TreeNode[TreeDataType]): A node. + + Returns: + int: Width in cells. + """ + label = self.render_label(node, NULL_STYLE, NULL_STYLE) + return label.cell_len + def clear(self) -> None: """Clear all nodes under root.""" self._tree_lines_cached = None @@ -508,25 +516,25 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): previous_node = self._get_node(previous_hover_line) if previous_node is not None: self._refresh_node(previous_node) - previous_node.hover = False + previous_node._hover = False node = self._get_node(hover_line) if node is not None: self._refresh_node(node) - node.hover = True + node._hover = True def watch_cursor_line(self, previous_line: int, line: int) -> None: previous_node = self._get_node(previous_line) if previous_node is not None: self._refresh_node(previous_node) - previous_node.selected = False - self.cursor_node = None + previous_node._selected = False + self._cursor_node = None node = self._get_node(line) if node is not None: self._refresh_node(node) - node.selected = True - self.cursor_node = node + node._selected = True + self._cursor_node = node def watch_guide_depth(self, guide_depth: int) -> None: self._invalidate() @@ -588,7 +596,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): return self._tree_lines_cached def _build(self) -> None: + """Builds the tree by traversing nodes, and creating tree lines.""" + TreeLine = _TreeLine lines: list[_TreeLine] = [] add_line = lines.append @@ -597,10 +607,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None: child_path = [*path, node] node._line = len(lines) - add_line(_TreeLine(child_path, last)) + add_line(TreeLine(child_path, last)) if node._expanded: for last, child in loop_last(node._children): - add_node(child_path, child, last=last) + add_node(child_path, child, last) if self.show_root: add_node([], root, True) @@ -610,8 +620,16 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._tree_lines_cached = lines guide_depth = self.guide_depth + show_root = self.show_root + get_label_width = self.get_label_width + + def get_line_width(line: _TreeLine) -> int: + return get_label_width(line.node) + line._get_guide_width( + guide_depth, show_root + ) + if lines: - width = max([line._get_line_width(guide_depth) for line in lines]) + width = max([get_line_width(line) for line in lines]) else: width = self.size.width @@ -620,6 +638,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.cursor_line = self.cursor_node._line if self.cursor_line >= len(lines): self.cursor_line = -1 + self.refresh() def render_line(self, y: int) -> list[Segment]: width = self.size.width @@ -751,6 +770,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._invalidate() def _toggle_node(self, node: TreeNode[TreeDataType]) -> None: + if not node.allow_expand: + return if node.is_expanded: node.collapse() self.post_message_no_wait(self.NodeCollapsed(self, node)) @@ -768,10 +789,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._toggle_node(node) else: - if self.cursor_line == cursor_line: - await self.action("select_cursor") - else: - self.cursor_line = cursor_line + self.cursor_line = cursor_line + await self.action("select_cursor") def _on_styles_updated(self) -> None: self._invalidate() From b48a1402b8103ca16d5e338538620e9e08fb2c0e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Nov 2022 19:28:14 +0000 Subject: [PATCH 21/33] add inherited bindings --- CHANGELOG.md | 1 + src/textual/_compositor.py | 1 - src/textual/dom.py | 36 ++++++++++++++++++++-- src/textual/widget.py | 60 ++++++++++++++---------------------- src/textual/widgets/_tree.py | 32 +++++++++++++++++-- 5 files changed, 86 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 185cbb530..6cf15b690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185 - Display of keys in footer has more sensible defaults https://github.com/Textualize/textual/pull/1213 - Add App.get_key_display, allowing custom key_display App-wide https://github.com/Textualize/textual/pull/1213 +- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False ### Changed diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6027ce819..ab9d69eca 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -458,7 +458,6 @@ class Compositor: # Add top level (root) widget add_widget(root, size.region, size.region, ((0,),), layer_order, size.region) - root.log(map) return map, widgets @property diff --git a/src/textual/dom.py b/src/textual/dom.py index 4d5509804..1b56b750a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -22,7 +22,7 @@ from rich.tree import Tree from ._context import NoActiveAppError from ._node_list import NodeList -from .binding import Bindings, BindingType +from .binding import Binding, Bindings, BindingType from .color import BLACK, WHITE, Color from .css._error_tools import friendly_list from .css.constants import VALID_DISPLAY, VALID_VISIBILITY @@ -97,9 +97,16 @@ class DOMNode(MessagePump): # True if this node inherits the CSS from the base class. _inherit_css: ClassVar[bool] = True + + # True to inherit bindings from base class + _inherit_bindings: ClassVar[bool] = True + # List of names of base classes that inherit CSS _css_type_names: ClassVar[frozenset[str]] = frozenset() + # Generated list of bindings + _merged_bindings: ClassVar[Bindings] | None = None + def __init__( self, *, @@ -127,7 +134,7 @@ class DOMNode(MessagePump): self._auto_refresh: float | None = None self._auto_refresh_timer: Timer | None = None self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)} - self._bindings = Bindings(self.BINDINGS) + self._bindings = self._merged_bindings or Bindings() self._has_hover_style: bool = False self._has_focus_within: bool = False @@ -152,12 +159,16 @@ class DOMNode(MessagePump): """Perform an automatic refresh (set with auto_refresh property).""" self.refresh() - def __init_subclass__(cls, inherit_css: bool = True) -> None: + def __init_subclass__( + cls, inherit_css: bool = True, inherit_bindings: bool = True + ) -> None: super().__init_subclass__() cls._inherit_css = inherit_css + cls._inherit_bindings = inherit_bindings css_type_names: set[str] = set() for base in cls._css_bases(cls): css_type_names.add(base.__name__) + cls._merged_bindings = cls._merge_bindings() cls._css_type_names = frozenset(css_type_names) def get_component_styles(self, name: str) -> RenderStyles: @@ -205,6 +216,25 @@ class DOMNode(MessagePump): else: break + @classmethod + def _merge_bindings(cls) -> Bindings: + """Merge bindings from base classes. + + Returns: + Bindings: Merged bindings. + """ + bindings: list[Bindings] = [] + + for base in reversed(cls.__mro__): + if issubclass(base, DOMNode): + if not base._inherit_bindings: + bindings.clear() + bindings.append(Bindings(base.BINDINGS)) + keys = {} + for bindings_ in bindings: + keys.update(bindings_.keys) + return Bindings(keys.values()) + def _post_register(self, app: App) -> None: """Called when the widget is registered diff --git a/src/textual/widget.py b/src/textual/widget.py index 328914304..3dc821e95 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -174,6 +174,12 @@ class Widget(DOMNode): BINDINGS = [ Binding("up", "scroll_up", "Scroll Up", show=False), Binding("down", "scroll_down", "Scroll Down", show=False), + Binding("left", "scroll_left", "Scroll Up", show=False), + Binding("right", "scroll_right", "Scroll Right", show=False), + Binding("home", "scroll_home", "Scroll Home", show=False), + Binding("end", "scroll_end", "Scroll End", show=False), + Binding("pageup", "page_up", "Page Up", show=False), + Binding("pagedown", "page_down", "Page Down", show=False), ] DEFAULT_CSS = """ @@ -1816,9 +1822,13 @@ class Widget(DOMNode): can_focus: bool | None = None, can_focus_children: bool | None = None, inherit_css: bool = True, + inherit_bindings: bool = True, ) -> None: base = cls.__mro__[0] - super().__init_subclass__(inherit_css=inherit_css) + super().__init_subclass__( + inherit_css=inherit_css, + inherit_bindings=inherit_bindings, + ) if issubclass(base, Widget): cls.can_focus = base.can_focus if can_focus is None else can_focus cls.can_focus_children = ( @@ -2345,53 +2355,21 @@ class Widget(DOMNode): def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None: self.scroll_to_region(message.region, animate=True) - def _key_home(self) -> bool: + def action_scroll_home(self) -> None: if self._allow_scroll: self.scroll_home() - return True - return False - def _key_end(self) -> bool: + def action_scroll_end(self) -> None: if self._allow_scroll: self.scroll_end() - return True - return False - def _key_left(self) -> bool: + def action_scroll_left(self) -> None: if self.allow_horizontal_scroll: self.scroll_left() - return True - return False - def _key_right(self) -> bool: + def action_scroll_right(self) -> None: if self.allow_horizontal_scroll: self.scroll_right() - return True - return False - - # def _key_down(self) -> bool: - # if self.allow_vertical_scroll: - # self.scroll_down() - # return True - # return False - - # def _key_up(self) -> bool: - # if self.allow_vertical_scroll: - # self.scroll_up() - # return True - # return False - - def _key_pagedown(self) -> bool: - if self.allow_vertical_scroll: - self.scroll_page_down() - return True - return False - - def _key_pageup(self) -> bool: - if self.allow_vertical_scroll: - self.scroll_page_up() - return True - return False def action_scroll_up(self) -> None: if self.allow_vertical_scroll: @@ -2400,3 +2378,11 @@ class Widget(DOMNode): def action_scroll_down(self) -> None: if self.allow_vertical_scroll: self.scroll_down() + + def action_page_down(self) -> None: + if self.allow_vertical_scroll: + self.scroll_page_down() + + def action_page_up(self) -> None: + if self.allow_vertical_scroll: + self.scroll_page_up() diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 7fac63784..3558f32bd 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -26,7 +26,7 @@ NodeID = NewType("NodeID", int) TreeDataType = TypeVar("TreeDataType") EventTreeDataType = TypeVar("EventTreeDataType") -LineCacheKey: TypeAlias = tuple[int | tuple[int, ...], ...] +LineCacheKey: TypeAlias = tuple[int | tuple, ...] TOGGLE_STYLE = Style.from_meta({"toggle": True}) @@ -199,9 +199,9 @@ class TreeNode(Generic[TreeDataType]): class Tree(Generic[TreeDataType], ScrollView, can_focus=True): BINDINGS = [ + Binding("enter", "select_cursor", "Select", show=False), Binding("up", "cursor_up", "Cursor Up", show=False), Binding("down", "cursor_down", "Cursor Down", show=False), - Binding("enter", "select_cursor", "Select", show=False), ] DEFAULT_CSS = """ @@ -334,6 +334,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """TreeNode | Node: The currently selected node, or ``None`` if no selection.""" return self._cursor_node + @property + def last_line(self) -> int: + return len(self._tree_lines) - 1 + def process_label(self, label: TextType): """Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered. @@ -797,15 +801,37 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def action_cursor_up(self) -> None: if self.cursor_line == -1: - self.cursor_line = len(self._tree_lines) - 1 + self.cursor_line = self.last_line else: self.cursor_line -= 1 self.scroll_to_line(self.cursor_line) def action_cursor_down(self) -> None: + if self.cursor_line == -1: + self.cursor_line = 0 self.cursor_line += 1 self.scroll_to_line(self.cursor_line) + def action_page_down(self) -> None: + if self.cursor_line == -1: + self.cursor_line = 0 + self.cursor_line += self.scrollable_content_region.height - 1 + self.scroll_to_line(self.cursor_line) + + def action_page_up(self) -> None: + if self.cursor_line == -1: + self.cursor_line = self.last_line + self.cursor_line -= self.scrollable_content_region.height - 1 + self.scroll_to_line(self.cursor_line) + + def action_scroll_home(self) -> None: + self.cursor_line = 0 + self.scroll_to_line(self.cursor_line) + + def action_scroll_end(self) -> None: + self.cursor_line = self.last_line + self.scroll_to_line(self.cursor_line) + def action_select_cursor(self) -> None: try: line = self._tree_lines[self.cursor_line] From 50d6e9736bbff5194ae8ac05a28717911a670a65 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Nov 2022 19:36:44 +0000 Subject: [PATCH 22/33] tweak --- examples/code_browser.css | 6 ++---- src/textual/widgets/_tree.py | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/code_browser.css b/examples/code_browser.css index 92b77c59b..9a2c295c9 100644 --- a/examples/code_browser.css +++ b/examples/code_browser.css @@ -5,12 +5,10 @@ Screen { #tree-view { display: none; scrollbar-gutter: stable; - overflow: auto; - overflow-y: scroll; + overflow: auto; width: auto; height: 100%; - dock: left; - + dock: left; } CodeBrowser.-show-tree #tree-view { diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 3558f32bd..51e797a5d 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -146,7 +146,6 @@ class TreeNode(Generic[TreeDataType]): self._updates += 1 text_label = self._tree.process_label(label) self._label = text_label - self._tree._invalidate() @property def is_expanded(self) -> bool: From 7ed734a524d650138c1f59bec88406f38894ce90 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Nov 2022 19:39:10 +0000 Subject: [PATCH 23/33] Add allow --- src/textual/widgets/_tree.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 51e797a5d..5de425494 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -166,6 +166,11 @@ class TreeNode(Generic[TreeDataType]): """bool: Check if the node is allowed to expand.""" return self._allow_expand + @allow_expand.setter + def allow_expand(self, allow_expand: bool) -> bool: + self._allow_expand = allow_expand + self._updates += 1 + def add( self, label: TextType, From 6ef4768e27957163f7d54397bf6e18e344431752 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Nov 2022 22:03:00 +0000 Subject: [PATCH 24/33] docstring --- src/textual/_cache.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index 8ee05bf8a..d6b877d30 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -47,6 +47,7 @@ class LRUCache(Generic[CacheKey, CacheValue]): @property def maxsize(self) -> int: + """int: Maximum size of cache, before new values evict old values.""" return self._maxsize @maxsize.setter From f7dade5a26f5cb0031222ad50722e35548311837 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 20 Nov 2022 15:42:35 +0000 Subject: [PATCH 25/33] new tree control --- docs/api/directory_tree.md | 1 + docs/api/tree.md | 1 + docs/api/tree_node.md | 1 + docs/examples/widgets/directory_tree.py | 12 + docs/examples/widgets/tree.py | 18 + docs/widgets/directory_tree.md | 36 ++ docs/widgets/tree.md | 46 ++ docs/widgets/tree_control.md | 1 - examples/json_tree.py | 79 ++++ examples/tree.py | 46 -- mkdocs.yml | 5 +- src/textual/widgets/__init__.py | 9 +- src/textual/widgets/__init__.pyi | 2 +- src/textual/widgets/_tree.py | 112 +++-- src/textual/widgets/_tree_control.py | 427 ------------------ src/textual/widgets/_tree_node.py | 1 + .../__snapshots__/test_snapshots.ambr | 156 +++++++ tests/snapshot_tests/test_snapshots.py | 7 + 18 files changed, 418 insertions(+), 542 deletions(-) create mode 100644 docs/api/directory_tree.md create mode 100644 docs/api/tree.md create mode 100644 docs/api/tree_node.md create mode 100644 docs/examples/widgets/directory_tree.py create mode 100644 docs/examples/widgets/tree.py create mode 100644 docs/widgets/directory_tree.md create mode 100644 docs/widgets/tree.md delete mode 100644 docs/widgets/tree_control.md create mode 100644 examples/json_tree.py delete mode 100644 examples/tree.py delete mode 100644 src/textual/widgets/_tree_control.py create mode 100644 src/textual/widgets/_tree_node.py diff --git a/docs/api/directory_tree.md b/docs/api/directory_tree.md new file mode 100644 index 000000000..f9d26e0e0 --- /dev/null +++ b/docs/api/directory_tree.md @@ -0,0 +1 @@ +::: textual.widgets.DirectoryTree diff --git a/docs/api/tree.md b/docs/api/tree.md new file mode 100644 index 000000000..73f20ee30 --- /dev/null +++ b/docs/api/tree.md @@ -0,0 +1 @@ +::: textual.widgets.Tree diff --git a/docs/api/tree_node.md b/docs/api/tree_node.md new file mode 100644 index 000000000..ad122443e --- /dev/null +++ b/docs/api/tree_node.md @@ -0,0 +1 @@ +::: textual.widgets.TreeNode diff --git a/docs/examples/widgets/directory_tree.py b/docs/examples/widgets/directory_tree.py new file mode 100644 index 000000000..e0c14a92c --- /dev/null +++ b/docs/examples/widgets/directory_tree.py @@ -0,0 +1,12 @@ +from textual.app import App, ComposeResult +from textual.widgets import DirectoryTree + + +class DirectoryTreeApp(App): + def compose(self) -> ComposeResult: + yield DirectoryTree("./") + + +if __name__ == "__main__": + app = DirectoryTreeApp() + app.run() diff --git a/docs/examples/widgets/tree.py b/docs/examples/widgets/tree.py new file mode 100644 index 000000000..7b6ff27d7 --- /dev/null +++ b/docs/examples/widgets/tree.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.widgets import Tree + + +class TreeApp(App): + def compose(self) -> ComposeResult: + tree: Tree = Tree("Dune") + tree.root.expand() + characters = tree.root.add("Characters", expand=True) + characters.add_leaf("Paul") + characters.add_leaf("Jessica") + characters.add_leaf("Channi") + yield tree + + +if __name__ == "__main__": + app = TreeApp() + app.run() diff --git a/docs/widgets/directory_tree.md b/docs/widgets/directory_tree.md new file mode 100644 index 000000000..2c5e327c1 --- /dev/null +++ b/docs/widgets/directory_tree.md @@ -0,0 +1,36 @@ +# DirectoryTree + +A tree control to navigate the contents of your filesystem. + +- [x] Focusable +- [ ] Container + + +## Example + +The example below creates a simple tree to navigate the current working directory. + +```python +--8<-- "docs/examples/widgets/directory_tree.py" +``` + +## Events + +| Event | Default handler | Description | +| ------------------- | --------------------------------- | --------------------------------------- | +| `Tree.FileSelected` | `on_directory_tree_file_selected` | Sent when the user selects a file node. | + + +## Reactive Attributes + +| Name | Type | Default | Description | +| ------------- | ------ | ------- | ----------------------------------------------- | +| `show_root` | `bool` | `True` | Show the root node. | +| `show_guides` | `bool` | `True` | Show guide lines between levels. | +| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. | + + +## See Also + +* [Tree][textual.widgets.DirectoryTree] code reference +* [Tree][textual.widgets.Tree] code reference diff --git a/docs/widgets/tree.md b/docs/widgets/tree.md new file mode 100644 index 000000000..d87e1a966 --- /dev/null +++ b/docs/widgets/tree.md @@ -0,0 +1,46 @@ +# Tree + +A tree control widget. + +- [x] Focusable +- [ ] Container + + +## Example + +The example below creates a simple tree. + +=== "Output" + + ```{.textual path="docs/examples/widgets/tree.py"} + ``` + +=== "tree.py" + + ```python + --8<-- "docs/examples/widgets/tree.py" + ``` + +A each tree widget has a "root" attribute which is an instance of a [TreeNode][textual.widgets.TreeNode]. Call [add()][textual.widgets.TreeNode.add] or [add_leaf()][textual.widgets.TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child, so you can add more levels. + +## Events + +| Event | Default handler | Description | +| -------------------- | ------------------------ | ------------------------------------------------ | +| `Tree.NodeSelected` | `on_tree_node_selected` | Sent when the user selects a tree node. | +| `Tree.NodeExpanded` | `on_tree_node_expanded` | Sent when the user expands a node in the tree. | +| `Tree.NodeCollapsed` | `on_tree_node_collapsed` | Sent when the user collapsed a node in the tree. | + +## Reactive Attributes + +| Name | Type | Default | Description | +| ------------- | ------ | ------- | ----------------------------------------------- | +| `show_root` | `bool` | `True` | Show the root node. | +| `show_guides` | `bool` | `True` | Show guide lines between levels. | +| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. | + + +## See Also + +* [Tree][textual.widgets.Tree] code reference +* [TreeNode][textual.widgets.TreeNode] code reference diff --git a/docs/widgets/tree_control.md b/docs/widgets/tree_control.md deleted file mode 100644 index 1155acfcc..000000000 --- a/docs/widgets/tree_control.md +++ /dev/null @@ -1 +0,0 @@ -# TreeControl diff --git a/examples/json_tree.py b/examples/json_tree.py new file mode 100644 index 000000000..d844556bb --- /dev/null +++ b/examples/json_tree.py @@ -0,0 +1,79 @@ +import json + +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, Tree, TreeNode + + +class TreeApp(App): + + BINDINGS = [ + ("a", "add", "Add node"), + ("c", "clear", "Clear"), + ("t", "toggle_root", "Toggle root"), + ] + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Tree("Root") + + @classmethod + def add_json(cls, node: TreeNode, json_data: object) -> None: + """Adds JSON data to a node. + + Args: + node (TreeNode): A Tree node. + json_data (object): An object decoded from JSON. + """ + + from rich.highlighter import ReprHighlighter + + highlighter = ReprHighlighter() + + def add_node(name: str, node: TreeNode, data: object) -> None: + if isinstance(data, dict): + node._label = Text(f"{{}} {name}") + for key, value in data.items(): + new_node = node.add("") + add_node(key, new_node, value) + elif isinstance(data, list): + node._label = Text(f"[] {name}") + for index, value in enumerate(data): + new_node = node.add("") + add_node(str(index), new_node, value) + else: + node._allow_expand = False + if name: + label = Text.assemble( + Text.from_markup(f"[b]{name}[/b]="), highlighter(repr(data)) + ) + else: + label = Text(repr(data)) + node._label = label + + add_node("JSON", node, json_data) + + def on_mount(self) -> None: + with open("food.json") as data_file: + self.json_data = json.load(data_file) + + def action_add(self) -> None: + tree = self.query_one(Tree) + json_node = tree.root.add("JSON") + self.add_json(json_node, self.json_data) + tree.root.expand() + + def action_clear(self) -> None: + tree = self.query_one(Tree) + tree.clear() + + def action_toggle_root(self) -> None: + tree = self.query_one(Tree) + tree.show_root = not tree.show_root + + +if __name__ == "__main__": + app = TreeApp() + app.run() diff --git a/examples/tree.py b/examples/tree.py deleted file mode 100644 index 688a505d7..000000000 --- a/examples/tree.py +++ /dev/null @@ -1,46 +0,0 @@ -import json - -from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, Tree, DirectoryTree - - -with open("food.json") as data_file: - data = json.load(data_file) - -from rich import print - -print(data) - - -class TreeApp(App): - - BINDINGS = [ - ("a", "add", "Add node"), - ("c", "clear", "Clear"), - ("t", "toggle_root", "Toggle root"), - ] - - def compose(self) -> ComposeResult: - yield Header() - yield Footer() - yield DirectoryTree("../") - - def action_add(self) -> None: - tree = self.query_one(Tree) - - json_node = tree.root.add("JSON") - tree.root.expand() - tree.add_json(json_node, data) - - def action_clear(self) -> None: - tree = self.query_one(Tree) - tree.clear() - - def action_toggle_root(self) -> None: - tree = self.query_one(Tree) - tree.show_root = not tree.show_root - - -if __name__ == "__main__": - app = TreeApp() - app.run() diff --git a/mkdocs.yml b/mkdocs.yml index 332298e5d..24edb147b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,16 +89,17 @@ nav: - "styles/visibility.md" - "styles/width.md" - Widgets: - - "widgets/index.md" - "widgets/button.md" - "widgets/checkbox.md" - "widgets/data_table.md" + - "widgets/directory_tree.md" - "widgets/footer.md" - "widgets/header.md" + - "widgets/index.md" - "widgets/input.md" - "widgets/label.md" - "widgets/static.md" - - "widgets/tree_control.md" + - "widgets/tree.md" - API: - "api/index.md" - "api/app.md" diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index e856b2bbf..4cf014383 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -8,22 +8,23 @@ from ..case import camel_to_snake # but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't # be able to "see" them. if typing.TYPE_CHECKING: - from ..widget import Widget + from ._button import Button from ._checkbox import Checkbox from ._data_table import DataTable from ._directory_tree import DirectoryTree from ._footer import Footer from ._header import Header + from ._input import Input from ._label import Label from ._placeholder import Placeholder from ._pretty import Pretty from ._static import Static - from ._input import Input from ._text_log import TextLog from ._tree import Tree - from ._tree_control import TreeControl + from ._tree_node import TreeNode from ._welcome import Welcome + from ..widget import Widget __all__ = [ "Button", @@ -39,7 +40,7 @@ __all__ = [ "Static", "TextLog", "Tree", - "TreeControl", + "TreeNode", "Welcome", ] diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 7530aef42..1d6b5c920 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -12,5 +12,5 @@ from ._static import Static as Static from ._input import Input as Input from ._text_log import TextLog as TextLog from ._tree import Tree as Tree -from ._tree_control import TreeControl as TreeControl +from ._tree_node import TreeNode as TreeNode from ._welcome import Welcome as Welcome diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 5de425494..83f9a3f67 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -56,6 +56,8 @@ class _TreeLine: @rich.repr.auto class TreeNode(Generic[TreeDataType]): + """An object that represents a "node" in a tree control.""" + def __init__( self, tree: Tree[TreeDataType], @@ -90,8 +92,9 @@ class TreeNode(Generic[TreeDataType]): self._selected_ = False self._updates += 1 + @property def line(self) -> int: - """Get the line number for this node, or -1 if it is not displayed.""" + """int: Get the line number for this node, or -1 if it is not displayed.""" return self._line @property @@ -116,9 +119,33 @@ class TreeNode(Generic[TreeDataType]): @property def id(self) -> NodeID: - """Get the node ID.""" + """NodeID: Get the node ID.""" return self._id + @property + def is_expanded(self) -> bool: + """bool: Check if the node is expanded.""" + return self._expanded + + @property + def is_last(self) -> bool: + """bool: Check if this is the last child.""" + if self._parent is None: + return True + return bool( + self._parent._children and self._parent._children[-1] == self, + ) + + @property + def allow_expand(self) -> bool: + """bool: Check if the node is allowed to expand.""" + return self._allow_expand + + @allow_expand.setter + def allow_expand(self, allow_expand: bool) -> None: + self._allow_expand = allow_expand + self._updates += 1 + def expand(self) -> None: """Expand a node (show its children).""" self._expanded = True @@ -147,30 +174,6 @@ class TreeNode(Generic[TreeDataType]): text_label = self._tree.process_label(label) self._label = text_label - @property - def is_expanded(self) -> bool: - """bool: Check if the node is expanded.""" - return self._expanded - - @property - def is_last(self) -> bool: - """bool: Check if this is the last child.""" - if self._parent is None: - return True - return bool( - self._parent._children and self._parent._children[-1] == self, - ) - - @property - def allow_expand(self) -> bool: - """bool: Check if the node is allowed to expand.""" - return self._allow_expand - - @allow_expand.setter - def allow_expand(self, allow_expand: bool) -> bool: - self._allow_expand = allow_expand - self._updates += 1 - def add( self, label: TextType, @@ -199,6 +202,21 @@ class TreeNode(Generic[TreeDataType]): self._tree._invalidate() return node + def add_leaf( + self, label: TextType, data: TreeDataType | None = None + ) -> TreeNode[TreeDataType]: + """Add a 'leaf' node (a node that can not expand). + + Args: + label (TextType): Label for the node. + data (TreeDataType | None, optional): Optional data. Defaults to None. + + Returns: + TreeNode[TreeDataType]: New node. + """ + node = self.add(label, data, expand=False, allow_expand=False) + return node + class Tree(Generic[TreeDataType], ScrollView, can_focus=True): @@ -451,36 +469,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): else: return line.node - def add_json(self, node: TreeNode, json_data: object) -> None: - - from rich.highlighter import ReprHighlighter - - highlighter = ReprHighlighter() - - def add_node(name: str, node: TreeNode, data: object) -> None: - if isinstance(data, dict): - node._label = Text(f"{{}} {name}") - for key, value in data.items(): - new_node = node.add("") - add_node(key, new_node, value) - elif isinstance(data, list): - node._label = Text(f"[] {name}") - for index, value in enumerate(data): - new_node = node.add("") - add_node(str(index), new_node, value) - else: - node._allow_expand = False - if name: - label = Text.assemble( - Text.from_markup(f"[b]{name}[/b]="), highlighter(repr(data)) - ) - else: - label = Text(repr(data)) - node._label = label - - add_node("JSON", node, json_data) - self._invalidate() - def validate_cursor_line(self, value: int) -> int: return clamp(value, 0, len(self._tree_lines) - 1) @@ -642,10 +630,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): width = self.size.width self.virtual_size = Size(width, len(lines)) - if self.cursor_node is not None: - self.cursor_line = self.cursor_node._line - if self.cursor_line >= len(lines): - self.cursor_line = -1 + if self.cursor_line != -1: + if self.cursor_node is not None: + self.cursor_line = self.cursor_node._line + if self.cursor_line >= len(lines): + self.cursor_line = -1 self.refresh() def render_line(self, y: int) -> list[Segment]: @@ -813,7 +802,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def action_cursor_down(self) -> None: if self.cursor_line == -1: self.cursor_line = 0 - self.cursor_line += 1 + else: + self.cursor_line += 1 self.scroll_to_line(self.cursor_line) def action_page_down(self) -> None: diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py deleted file mode 100644 index c471e0686..000000000 --- a/src/textual/widgets/_tree_control.py +++ /dev/null @@ -1,427 +0,0 @@ -from __future__ import annotations - - -from typing import ClassVar, Generic, Iterator, NewType, TypeVar - -import rich.repr -from rich.console import RenderableType -from rich.style import Style, NULL_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 ..widgets import Static -from ..message import Message -from .. import messages - - -NodeID = NewType("NodeID", int) - - -NodeDataType = TypeVar("NodeDataType") -EventNodeDataType = TypeVar("EventNodeDataType") - - -@rich.repr.auto -class TreeNode(Generic[NodeDataType]): - def __init__( - self, - parent: TreeNode[NodeDataType] | None, - node_id: NodeID, - control: TreeControl, - tree: Tree, - label: TextType, - data: NodeDataType, - ) -> None: - self.parent = parent - self.id = node_id - self._control = control - self._tree = tree - self.label = label - self.data = data - self.loaded = False - self._expanded = False - self._empty = False - self._tree.expanded = False - self.children: list[TreeNode] = [] - - def __rich_repr__(self) -> rich.repr.Result: - yield "id", self.id - yield "label", self.label - yield "data", self.data - - @property - def control(self) -> TreeControl: - return self._control - - @property - def empty(self) -> bool: - return self._empty - - @property - def expanded(self) -> bool: - return self._expanded - - @property - def is_cursor(self) -> bool: - return self.control.cursor == self.id and self.control.show_cursor - - @property - def tree(self) -> Tree: - return self._tree - - @property - def next_node(self) -> TreeNode[NodeDataType] | None: - """The next node in the tree, or None if at the end.""" - - if self.expanded and self.children: - return self.children[0] - else: - - sibling = self.next_sibling - if sibling is not None: - return sibling - - node = self - while True: - if node.parent is None: - return None - sibling = node.parent.next_sibling - if sibling is not None: - return sibling - else: - node = node.parent - - @property - def previous_node(self) -> TreeNode[NodeDataType] | None: - """The previous node in the tree, or None if at the end.""" - - sibling = self.previous_sibling - if sibling is not None: - - def last_sibling(node) -> TreeNode[NodeDataType]: - if node.expanded and node.children: - return last_sibling(node.children[-1]) - else: - return ( - node.children[-1] if (node.children and node.expanded) else node - ) - - return last_sibling(sibling) - - if self.parent is None: - return None - return self.parent - - @property - def next_sibling(self) -> TreeNode[NodeDataType] | None: - """The next sibling, or None if last sibling.""" - if self.parent is None: - return None - iter_siblings = iter(self.parent.children) - try: - for node in iter_siblings: - if node is self: - return next(iter_siblings) - except StopIteration: - pass - return None - - @property - def previous_sibling(self) -> TreeNode[NodeDataType] | None: - """Previous sibling or None if first sibling.""" - if self.parent is None: - return None - iter_siblings = iter(self.parent.children) - sibling: TreeNode[NodeDataType] | None = None - - for node in iter_siblings: - if node is self: - return sibling - sibling = node - return None - - def expand(self, expanded: bool = True) -> None: - self._expanded = expanded - self._tree.expanded = expanded - self._control.refresh(layout=True) - - def toggle(self) -> None: - self.expand(not self._expanded) - - def add(self, label: TextType, data: NodeDataType) -> None: - self._control.add(self.id, label, data=data) - self._control.refresh(layout=True) - self._empty = False - - def __rich__(self) -> RenderableType: - return self._control.render_node(self) - - -class TreeControl(Generic[NodeDataType], Static, can_focus=True): - DEFAULT_CSS = """ - TreeControl { - color: $text; - height: auto; - width: 100%; - link-style: not underline; - } - - TreeControl > .tree--guides { - 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; - } - - TreeControl > .tree--cursor { - background: $secondary; - color: $text; - } - - """ - - 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, - data: NodeDataType, - *, - name: str | None = None, - id: str | None = None, - classes: str | None = None, - ) -> None: - super().__init__(name=name, id=id, classes=classes) - self.data = data - - 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 - ) - - self._tree.label = self.root - self.nodes[NodeID(self.node_id)] = self.root - - self.auto_links = False - - hover_node: Reactive[NodeID | None] = Reactive(None) - cursor: Reactive[NodeID] = Reactive(NodeID(0)) - cursor_line: Reactive[int] = Reactive(0) - show_cursor: Reactive[bool] = Reactive(False) - - def watch_cursor_line(self, value: int) -> None: - 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 - ) - - 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 - ) - parent.children.append(child_node) - child_tree.label = child_node - self.nodes[self.node_id] = child_node - - self.refresh(layout=True) - - def find_cursor(self) -> int | None: - """Find the line location for the cursor node.""" - - node_id = self.cursor - line = 0 - - stack: list[Iterator[TreeNode[NodeDataType]]] - stack = [iter([self.root])] - - pop = stack.pop - push = stack.append - while stack: - iter_children = pop() - try: - node = next(iter_children) - except StopIteration: - continue - else: - if node.id == node_id: - return line - line += 1 - push(iter_children) - if node.children and node.expanded: - push(iter(node.children)) - return None - - def render(self) -> RenderableType: - 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, style=label_style, overflow="ellipsis") - if isinstance(node.label, str) - else node.label - ) - if node.id == self.hover_node: - label.stylize("underline") - label.apply_meta({"@click": f"click_label({node.id})", "tree_node": node.id}) - return label - - 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 = True - self.post_message_no_wait(self.NodeSelected(self, node)) - - def on_mount(self) -> None: - self._tree.guide_style = self._guide_style - - @property - def _guide_style(self) -> Style: - return self.get_component_rich_style("tree--guides") - - @property - def _highlight_guide_style(self) -> Style: - return self.get_component_rich_style("tree--guides-highlight") - - @property - def _cursor_guide_style(self) -> Style: - return self.get_component_rich_style("tree--guides-cursor") - - def on_mouse_move(self, event: events.MouseMove) -> None: - self.hover_node = event.style.meta.get("tree_node") - - def key_down(self, event: events.Key) -> None: - event.stop() - self.cursor_down() - - def key_up(self, event: events.Key) -> None: - event.stop() - 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 - - def key_enter(self, event: events.Key) -> None: - cursor_node = self.nodes[self.cursor] - event.stop() - self.post_message_no_wait(self.NodeSelected(self, cursor_node)) - - def cursor_down(self) -> None: - if not self.show_cursor: - self.show_cursor = True - return - cursor_node = self.nodes[self.cursor] - next_node = cursor_node.next_node - if next_node is not None: - self.cursor_line += 1 - self.cursor = next_node.id - - def cursor_up(self) -> None: - if not self.show_cursor: - self.show_cursor = True - return - cursor_node = self.nodes[self.cursor] - previous_node = cursor_node.previous_node - if previous_node is not None: - self.cursor_line -= 1 - self.cursor = previous_node.id diff --git a/src/textual/widgets/_tree_node.py b/src/textual/widgets/_tree_node.py new file mode 100644 index 000000000..e6c57fb61 --- /dev/null +++ b/src/textual/widgets/_tree_node.py @@ -0,0 +1 @@ +from ._tree import TreeNode as TreeNode diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 354a78d0f..977ab4286 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -6792,6 +6792,162 @@ ''' # --- +# name: test_tree_example + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ Dune + └── ▼ Characters +     ├── Paul +     ├── Jessica +     └── Channi + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_vertical_layout ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index f5116cc78..aae9669db 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -11,6 +11,7 @@ SNAPSHOT_APPS_DIR = Path("./snapshot_apps") # --- Layout related stuff --- + def test_grid_layout_basic(snap_compare): assert snap_compare(LAYOUT_EXAMPLES_DIR / "grid_layout1.py") @@ -48,6 +49,7 @@ def test_dock_layout_sidebar(snap_compare): # When adding a new widget, ideally we should also create a snapshot test # from these examples which test rendering and simple interactions with it. + def test_checkboxes(snap_compare): """Tests checkboxes but also acts a regression test for using width: auto in a Horizontal layout context.""" @@ -98,6 +100,10 @@ def test_fr_units(snap_compare): assert snap_compare("snapshot_apps/fr_units.py") +def test_tree_example(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "tree.py") + + # --- CSS properties --- # We have a canonical example for each CSS property that is shown in their docs. # If any of these change, something has likely broken, so snapshot each of them. @@ -122,5 +128,6 @@ def test_multiple_css(snap_compare): # --- Other --- + def test_key_display(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py") From 90a2a99addfefd737164ccc1acbf083a6069a31d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 20 Nov 2022 15:47:15 +0000 Subject: [PATCH 26/33] update changelog --- CHANGELOG.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf15b690..312595a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). + +## [0.6.0] + +### Added + +- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False +- Added `Tree` widget which replaces `TreeControl`. + +### Changed + +- Rebuilt `DirectoryTree` with new `Tree` control. + ## [0.5.0] - Unreleased ### Added @@ -30,7 +42,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185 - Display of keys in footer has more sensible defaults https://github.com/Textualize/textual/pull/1213 - Add App.get_key_display, allowing custom key_display App-wide https://github.com/Textualize/textual/pull/1213 -- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False ### Changed From 62a1dc60cdba7e97e43e6f598ec743d6d27b7f1b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 20 Nov 2022 16:55:19 +0000 Subject: [PATCH 27/33] fix type error --- src/textual/widgets/_directory_tree.py | 2 ++ src/textual/widgets/_tree.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 841fd30ef..459afa1ea 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -14,6 +14,8 @@ from .._types import MessageTarget @dataclass class DirEntry: + """Attaches directory information ot a node.""" + path: str is_dir: bool loaded: bool = False diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 83f9a3f67..956656caf 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -26,7 +26,7 @@ NodeID = NewType("NodeID", int) TreeDataType = TypeVar("TreeDataType") EventTreeDataType = TypeVar("EventTreeDataType") -LineCacheKey: TypeAlias = tuple[int | tuple, ...] +LineCacheKey: TypeAlias = "tuple[int | tuple, ...]" TOGGLE_STYLE = Style.from_meta({"toggle": True}) From b7d628fca5b84bf330f51fe9f487eccf63d775e9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 20 Nov 2022 17:32:37 +0000 Subject: [PATCH 28/33] docs update --- docs/widgets/directory_tree.md | 15 ++++++++--- docs/widgets/tree.md | 48 +++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/docs/widgets/directory_tree.md b/docs/widgets/directory_tree.md index 2c5e327c1..2baf73e75 100644 --- a/docs/widgets/directory_tree.md +++ b/docs/widgets/directory_tree.md @@ -14,12 +14,19 @@ The example below creates a simple tree to navigate the current working director --8<-- "docs/examples/widgets/directory_tree.py" ``` -## Events +## Messages -| Event | Default handler | Description | -| ------------------- | --------------------------------- | --------------------------------------- | -| `Tree.FileSelected` | `on_directory_tree_file_selected` | Sent when the user selects a file node. | +### FileSelected +The `DirectoryTree.FileSelected` message is sent when the user selects a file in the tree + +- [x] Bubbles + +#### Attributes + +| attribute | type | purpose | +| --------- | ----- | ----------------- | +| `path` | `str` | Path of the file. | ## Reactive Attributes diff --git a/docs/widgets/tree.md b/docs/widgets/tree.md index d87e1a966..801d993f0 100644 --- a/docs/widgets/tree.md +++ b/docs/widgets/tree.md @@ -23,13 +23,6 @@ The example below creates a simple tree. A each tree widget has a "root" attribute which is an instance of a [TreeNode][textual.widgets.TreeNode]. Call [add()][textual.widgets.TreeNode.add] or [add_leaf()][textual.widgets.TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child, so you can add more levels. -## Events - -| Event | Default handler | Description | -| -------------------- | ------------------------ | ------------------------------------------------ | -| `Tree.NodeSelected` | `on_tree_node_selected` | Sent when the user selects a tree node. | -| `Tree.NodeExpanded` | `on_tree_node_expanded` | Sent when the user expands a node in the tree. | -| `Tree.NodeCollapsed` | `on_tree_node_collapsed` | Sent when the user collapsed a node in the tree. | ## Reactive Attributes @@ -40,6 +33,47 @@ A each tree widget has a "root" attribute which is an instance of a [TreeNode][t | `guide_depth` | `int` | `4` | Amount of indentation between parent and child. | + +## Messages + +### NodeSelected + +The `Tree.NodeSelected` message is sent when the user selects a tree node. + + +#### Attributes + +| attribute | type | purpose | +| --------- | ------------------------------------ | -------------- | +| `node` | [TreeNode][textual.widgets.TreeNode] | Selected node. | + + +### NodeExpanded + +The `Tree.NodeExpanded` message is sent when the user expands a node in the tree. + +#### Attributes + +| attribute | type | purpose | +| --------- | ------------------------------------ | -------------- | +| `node` | [TreeNode][textual.widgets.TreeNode] | Expanded node. | + + +### NodeCollapsed + + +The `Tree.NodeCollapsed` message is sent when the user expands a node in the tree. + + +#### Attributes + +| attribute | type | purpose | +| --------- | ------------------------------------ | --------------- | +| `node` | [TreeNode][textual.widgets.TreeNode] | Collapsed node. | + + + + ## See Also * [Tree][textual.widgets.Tree] code reference From 65fc534f88c0d054e1599c360801d4256cb32b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+RodrigoGiraoSerrao@users.noreply.github.com> Date: Tue, 22 Nov 2022 12:11:57 +0000 Subject: [PATCH 29/33] Add blog post about the placeholder PR. --- docs/blog/images/placeholder-example.svg | 165 ++++++++++++++++ docs/blog/posts/placeholder-pr.md | 233 +++++++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 docs/blog/images/placeholder-example.svg create mode 100644 docs/blog/posts/placeholder-pr.md diff --git a/docs/blog/images/placeholder-example.svg b/docs/blog/images/placeholder-example.svg new file mode 100644 index 000000000..63ac40e97 --- /dev/null +++ b/docs/blog/images/placeholder-example.svg @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PlaceholderApp + + + + + + + + + + + Placeholder p2 here! + This is a custom label for p1. + #p4 + #p3#p5Placeholde + r + + Lorem ipsum dolor sit  + 26 x 6amet, consectetur 27 x 6 + adipiscing elit. Etiam  + feugiat ac elit sit  + + + Lorem ipsum dolor sit amet,  + consectetur adipiscing elit. Etiam 40 x 6 + feugiat ac elit sit amet accumsan.  + Suspendisse bibendum nec libero quis + gravida. Phasellus id eleifend  + ligula. Nullam imperdiet sem tellus, + sed vehicula nisl faucibus sit amet.Lorem ipsum dolor sit amet,  + Praesent iaculis tempor ultricies. ▆▆consectetur adipiscing elit. Etiam ▆▆ + Sed lacinia, tellus id rutrum feugiat ac elit sit amet accumsan.  + lacinia, sapien sapien congue Suspendisse bibendum nec libero quis + + + + diff --git a/docs/blog/posts/placeholder-pr.md b/docs/blog/posts/placeholder-pr.md new file mode 100644 index 000000000..1208a97fe --- /dev/null +++ b/docs/blog/posts/placeholder-pr.md @@ -0,0 +1,233 @@ +--- +draft: false +date: 2022-11-22 +categories: + - DevLog +authors: + - rodrigo +--- + + +# What I learned from my first non-trivial PR + +
+--8<-- "docs/blog/images/placeholder-example.svg" +
+ +It's 8:59 am and, by my Portuguese standards, it is freezing cold outside: 5 or 6 degrees Celsius. +It is my second day at Textualize and I just got into the office. +I undress my many layers of clothing to protect me from the Scottish cold and I sit down in my improvised corner of the Textualize office. +As I sit down, I turn myself in my chair to face my boss and colleagues to ask “So, what should I do today?”. +I was not expecting Will's answer, but the challenge excited me: + + + + > “I thought I'll just throw you in the deep end and have you write some code.” + +What happened next was that I spent two days [working on PR #1229](https://github.com/Textualize/textual/pull/1229) to add a new widget to the [Textual](https://github.com/Textualize/textual) code base. +At the time of writing, the pull request has not been merged yet. +Well, to be honest with you, it hasn't even been reviewed by anyone... +But that won't stop me from blogging about some of the things I learned while creating this PR. + + +## The placeholder widget + +This PR adds a widget called `Placeholder` to Textual. +As per the documentation, this widget “is meant to have no complex functionality. +Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.” + +The point of the placeholder widget is that you can focus on building the layout of your app without having to have all of your (custom) widgets ready. +The placeholder widget also displays a couple of useful pieces of information to help you work out the layout of your app, namely the ID of the widget itself (or a custom label, if you provide one) and the width and height of the widget. + +As an example of usage of the placeholder widget, you can refer to the screenshot at the top of this blog post, which I included below so you don't have to scroll up: + +
+--8<-- "docs/blog/images/placeholder-example.svg" +
+ +The top left and top right widgets have custom labels. +Immediately under the top right placeholder, you can see some placeholders identified as `#p3`, `#p4`, and `#p5`. +Those are the IDs of the respective placeholders. +Then, rows 2 and 3 contain some placeholders that show their respective size and some placeholders that just contain some text. + + +## Bootstrapping the code for the widget + +So, how does a code monkey start working on a non-trivial PR within 24 hours of joining a company? +The answer is simple: just copy and paste code! +But instead of copying and pasting from Stack Overflow, I decided to copy and paste from the internal code base. + +My task was to create a new widget, so I thought it would be a good idea to take a look at the implementation of other Textual widgets. +For some reason I cannot seem to recall, I decided to take a look at the implementation of the button widget that you can find in [_button.py](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_button.py). +By looking at how the button widget is implemented, I could immediately learn a few useful things about what I needed to do and some other things about how Textual works. + +For example, a widget can have a class attribute called `DEFAULT_CSS` that specifies the default CSS for that widget. +I learned this just from staring at the code for the button widget. + +Studying the code base will also reveal the standards that are in place. +For example, I learned that for a widget with variants (like the button with its “success” and “error” variants), the widget gets a CSS class with the name of the variant prefixed by a dash. +You can learn this by looking at the method `Button.watch_variant`: + +```py +class Button(Static, can_focus=True): + # ... + + def watch_variant(self, old_variant: str, variant: str): + self.remove_class(f"-{old_variant}") + self.add_class(f"-{variant}") +``` + +In short, looking at code and files that are related to the things you need to do is a great way to get information about things you didn't even know you needed. + + +## Handling the placeholder variant + +A button widget can have a different variant, which is mostly used by Textual to determine the CSS that should apply to the given button. +For the placeholder widget, we want the variant to determine what information the placeholder shows. +The [original GitHub issue](https://github.com/Textualize/textual/issues/1200) mentions 5 variants for the placeholder: + + - a variant that just shows a label or the placeholder ID; + - a variant that shows the size and location of the placeholder; + - a variant that shows the state of the placeholder (does it have focus? is the mouse over it?); + - a variant that shows the CSS that is applied to the placeholder itself; and + - a variant that shows some text inside the placeholder. + +The variant can be assigned when the placeholder is first instantiated, for example, `Placeholder("css")` would create a placeholder that shows its own CSS. +However, we also want to have an `on_click` handler that cycles through all the possible variants. +I was getting ready to reinvent the wheel when I remembered that the standard module [`itertools`](https://docs.python.org/3/library/itertools) has a lovely tool that does exactly what I needed! +Thus, all I needed to do was create a new `cycle` through the variants each time a placeholder is created and then grab the next variant whenever the placeholder is clicked: + +```py +class Placeholder(Static): + def __init__( + self, + variant: PlaceholderVariant = "default", + *, + label: str | None = None, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + # ... + + self.variant = self.validate_variant(variant) + # Set a cycle through the variants with the correct starting point. + self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED) + while next(self._variants_cycle) != self.variant: + pass + + def on_click(self) -> None: + """Click handler to cycle through the placeholder variants.""" + self.cycle_variant() + + def cycle_variant(self) -> None: + """Get the next variant in the cycle.""" + self.variant = next(self._variants_cycle) +``` + +I am just happy that I had the insight to add this little `while` loop when a placeholder is instantiated: + +```py +from itertools import cycle +# ... +class Placeholder(Static): + # ... + def __init__(...): + # ... + self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED) + while next(self._variants_cycle) != self.variant: + pass +``` + +Can you see what would be wrong if this loop wasn't there? + + +## Updating the render of the placeholder on variant change + +If the variant of the placeholder is supposed to determine what information the placeholder shows, then that information must be updated every time the variant of the placeholder changes. +Thankfully, Textual has reactive attributes and watcher methods, so all I needed to do was... +Defer the problem to another method: + +```py +class Placeholder(Static): + # ... + variant = reactive("default") + # ... + def watch_variant( + self, old_variant: PlaceholderVariant, variant: PlaceholderVariant + ) -> None: + self.validate_variant(variant) + self.remove_class(f"-{old_variant}") + self.add_class(f"-{variant}") + self.call_variant_update() # <-- let this method do the heavy lifting! +``` + +Doing this properly required some thinking. +Not that the current proposed solution is the best possible, but I did think of worse alternatives while I was thinking how to tackle this. +I wasn't entirely sure how I would manage the variant-dependant rendering because I am not a fan of huge conditional statements that look like switch statements: + +```py +if variant == "default": + # render the default placeholder +elif variant == "size": + # render the placeholder with its size +elif variant == "state": + # render the state of the placeholder +elif variant == "css": + # render the placeholder with its CSS rules +elif variant == "text": + # render the placeholder with some text inside +``` + +However, I am a fan of using the built-in `getattr` and I thought of creating a rendering method for each different variant. +Then, all I needed to do was make sure the variant is part of the name of the method so that I can programmatically determine the name of the method that I need to call. +This means that the method `Placeholder.call_variant_update` is just this: + +```py +class Placeholder(Static): + # ... + def call_variant_update(self) -> None: + """Calls the appropriate method to update the render of the placeholder.""" + update_variant_method = getattr(self, f"_update_{self.variant}_variant") + update_variant_method() +``` + +If `self.variant` is, say, `"size"`, then `update_variant_method` refers to `_update_size_variant`: + +```py +class Placeholder(Static): + # ... + def _update_size_variant(self) -> None: + """Update the placeholder with the size of the placeholder.""" + width, height = self.size + self._placeholder_label.update(f"[b]{width} x {height}[/b]") +``` + +This variant `"size"` also interacts with resizing events, so we have to watch out for those: + +```py +class Placeholder(Static): + # ... + def on_resize(self, event: events.Resize) -> None: + """Update the placeholder "size" variant with the new placeholder size.""" + if self.variant == "size": + self._update_size_variant() +``` + + +## Deleting code is a (hurtful) blessing + +To conclude this blog post, let me muse about the fact that the original issue mentioned five placeholder variants and that my PR only includes two and a half. + +After careful consideration and after coming up with the `getattr` mechanism to update the display of the placeholder according to the active variant, I started showing the “final” product to Will and my other colleagues. +Eventually, we ended up getting rid of the variant for CSS and the variant that shows the placeholder state. +This means that I had to **delete part of my code** even before it saw the light of day. + +On the one hand, deleting those chunks of code made me a bit sad. +After all, I had spent quite some time thinking about how to best implement that functionality! +But then, it was time to write documentation and tests, and I verified that the **best code** is the code that you don't even write! +The code you don't write is guaranteed to have zero bugs and it also does not need any documentation whatsoever! + +So, it was a shame that some lines of code I poured my heart and keyboard into did not get merged into the Textual code base. +On the other hand, I am quite grateful that I won't have to fix the bugs that will certainly reveal themselves in a couple of weeks or months from now. +Heck, the code hasn't been merged yet and just by writing this blog post I noticed a couple of tweaks that were missing! From 66b998dbc14b1e3b87c261c5dcdcd8a14f13ee12 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 23 Nov 2022 17:04:07 +0800 Subject: [PATCH 30/33] Update src/textual/widgets/_tree.py Co-authored-by: Dave Pearson --- src/textual/widgets/_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 956656caf..6be605cf3 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -38,7 +38,7 @@ class _TreeLine: @property def node(self) -> TreeNode: - """The node associated with this line.""" + """TreeNode: The node associated with this line.""" return self.path[-1] def _get_guide_width(self, guide_depth: int, show_root: bool) -> int: From 94ee237ab06d4d6f4891ddafb632a71c31808e0c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 23 Nov 2022 17:14:02 +0800 Subject: [PATCH 31/33] docstring --- src/textual/widgets/_tree.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 6be605cf3..9f57b0834 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -275,11 +275,17 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): } show_root = reactive(True) + """bool: Show the root of the tree.""" hover_line = var(-1) + """int: The line number under the mouse pointer, or -1 if not under the mouse pointer.""" cursor_line = var(-1) + """int: The line with the cursor, or -1 if no cursor.""" show_guides = reactive(True) + """bool: Enable display of tree guide lines.""" guide_depth = reactive(4, init=False) + """int: The indent depth of tree nodes.""" auto_expand = var(True) + """bool: Auto expand tree nodes when clicked.""" LINES: dict[str, tuple[str, str, str, str]] = { "default": ( @@ -395,6 +401,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): Args: node (TreeNode[TreeDataType]): A tree node. + base_style (Style): The base style of the widget. + style (Style): The additional style for the label. Returns: Text: A Rich Text object containing the label. @@ -470,9 +478,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): return line.node def validate_cursor_line(self, value: int) -> int: + """Prevent cursor line from going outside of range.""" return clamp(value, 0, len(self._tree_lines) - 1) def validate_guide_depth(self, value: int) -> int: + """Restrict guide depth to reasonable range.""" return clamp(value, 2, 10) def _invalidate(self) -> None: From cf539293ff3b61eadb7545dae5787bbc375158da Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 23 Nov 2022 17:15:03 +0800 Subject: [PATCH 32/33] docstrings --- src/textual/widgets/_tree.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 9f57b0834..ac3ca5a61 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -364,6 +364,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): @property def last_line(self) -> int: + """int: the index of the last line.""" return len(self._tree_lines) - 1 def process_label(self, label: TextType): From 10a3fb1d1b68a6ef3a32e1cc380295993d01cd23 Mon Sep 17 00:00:00 2001 From: Josh Karpel Date: Wed, 23 Nov 2022 04:08:32 -0600 Subject: [PATCH 33/33] Allow `Driver`s to handle key events after being restarted (#1150) --- src/textual/drivers/linux_driver.py | 1 + src/textual/drivers/windows_driver.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 8f61c4fb2..f5f90ae0b 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -176,6 +176,7 @@ class LinuxDriver(Driver): self.exit_event.set() if self._key_thread is not None: self._key_thread.join() + self.exit_event.clear() termios.tcflush(self.fileno, termios.TCIFLUSH) except Exception as error: # TODO: log this diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index b14af7ab5..0899f65ef 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -84,6 +84,7 @@ class WindowsDriver(Driver): if self._event_thread is not None: self._event_thread.join() self._event_thread = None + self.exit_event.clear() except Exception as error: # TODO: log this pass