Merge pull request #1170 from Textualize/new-tree

New Tree Control
This commit is contained in:
Will McGugan
2022-11-23 17:19:59 +08:00
committed by GitHub
29 changed files with 3478 additions and 576 deletions

View File

@@ -1,11 +1,22 @@
# Change Log
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] - Unreleased
### 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] - 2022-11-20
### Added

View File

@@ -0,0 +1 @@
::: textual.widgets.DirectoryTree

1
docs/api/tree.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.Tree

1
docs/api/tree_node.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.TreeNode

View File

@@ -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()

View File

@@ -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()

View File

@@ -0,0 +1,43 @@
# 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"
```
## Messages
### 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
| 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

80
docs/widgets/tree.md Normal file
View File

@@ -0,0 +1,80 @@
# 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.
## 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. |
## 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
* [TreeNode][textual.widgets.TreeNode] code reference

View File

@@ -1 +0,0 @@
# TreeControl

View File

@@ -3,22 +3,19 @@ Screen {
}
#tree-view {
display: none;
display: none;
scrollbar-gutter: stable;
width: auto;
overflow: auto;
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;

View File

@@ -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(

1944
examples/food.json Normal file

File diff suppressed because it is too large Load Diff

79
examples/json_tree.py Normal file
View File

@@ -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()

View File

@@ -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"

View File

@@ -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
@@ -59,6 +60,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:

View File

@@ -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

View File

@@ -551,6 +551,22 @@ 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.has_rule("color") else None),
bgcolor=(
self.background.rich_color if self.has_rule("background") else None
),
)
style += self.text_style
return style
@rich.repr.auto
@dataclass

View File

@@ -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

View File

@@ -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()

View File

@@ -1,15 +1,16 @@
from __future__ import annotations
from collections import Counter
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,
@@ -32,7 +33,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
@@ -40,7 +41,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.query import NoMatches
from .css.scalar import ScalarOffset
@@ -169,6 +171,17 @@ 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 = """
Widget{
scrollbar-background: $panel-darken-1;
@@ -237,7 +250,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()
@@ -374,20 +387,26 @@ class Widget(DOMNode):
pass
raise NoMatches(f"No descendant found with id={id!r}")
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:
name (str): Name of component.
partial (bool, optional): Return a partial style (not combined with parent).
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
partial_style = component_styles.partial_rich_style
self._rich_style_cache[name] = (style, partial_style)
style, partial_style = self._rich_style_cache[name]
return partial_style if partial else style
def _arrange(self, size: Size) -> DockArrangeResult:
"""Arrange children.
@@ -903,8 +922,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
@@ -966,6 +983,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.
@@ -1731,7 +1760,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)
@@ -1793,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 = (
@@ -2322,50 +2355,34 @@ 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:
def action_scroll_up(self) -> None:
if self.allow_vertical_scroll:
self.scroll_up()
return True
return False
def _key_pagedown(self) -> bool:
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()
return True
return False
def _key_pageup(self) -> bool:
def action_page_up(self) -> None:
if self.allow_vertical_scroll:
self.scroll_page_up()
return True
return False

View File

@@ -8,21 +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_control import TreeControl
from ._tree import Tree
from ._tree_node import TreeNode
from ._welcome import Welcome
from ..widget import Widget
__all__ = [
"Button",
@@ -31,13 +33,14 @@ __all__ = [
"DirectoryTree",
"Footer",
"Header",
"Input",
"Label",
"Placeholder",
"Pretty",
"Static",
"Input",
"TextLog",
"TreeControl",
"Tree",
"TreeNode",
"Welcome",
]

View File

@@ -11,5 +11,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_control import TreeControl as TreeControl
from ._tree import Tree as Tree
from ._tree_node import TreeNode as TreeNode
from ._welcome import Welcome as Welcome

View File

@@ -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:

View File

@@ -1,29 +1,61 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from os import scandir
import os.path
from pathlib import Path
from typing import ClassVar
from rich.console import RenderableType
import rich.repr
from rich.text import Text
from rich.style import Style
from rich.text import Text, TextType
from ..message import Message
from ._tree import Tree, TreeNode, TOGGLE_STYLE
from .._types import MessageTarget
from ._tree_control import TreeControl, TreeNode
@dataclass
class DirEntry:
"""Attaches directory information ot a node."""
path: str
is_dir: bool
loaded: bool = False
class DirectoryTree(TreeControl[DirEntry]):
@rich.repr.auto
class FileClick(Message, bubble=True):
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%;
}
"""
class FileSelected(Message, bubble=True):
def __init__(self, sender: MessageTarget, path: str) -> None:
self.path = path
super().__init__(sender)
@@ -36,84 +68,97 @@ 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 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:
icon = "📄"
label.highlight_regex(r"\..*$", "italic")
text_label = label
first_line = text_label.split()[0]
return first_line
if label.plain.startswith("."):
label.stylize("dim")
def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style):
node_label = node._label.copy()
node_label.stylize(style)
if is_cursor and has_focus:
cursor_style = self.get_component_styles("tree--cursor").rich_style
label.stylize(cursor_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:
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
),
)
icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label
icon_label.apply_meta(meta)
return icon_label
if node_label.plain.startswith("."):
node_label.stylize_before(
self.get_component_rich_style("directory-tree--hidden")
)
def on_styles_updated(self) -> None:
self.render_tree_label.cache_clear()
text = Text.assemble(prefix, node_label)
return text
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))
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:
if not dir_entry.loaded:
self.load_directory(event.node)
else:
if not message.node.loaded:
await self.load_directory(message.node)
message.node.expand()
else:
message.node.toggle()
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))

View File

@@ -0,0 +1,849 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar, Generic, NewType, TypeVar
import rich.repr
from rich.segment import Segment
from rich.style import Style, NULL_STYLE
from rich.text import Text, TextType
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, var
from .._segment_tools import line_crop, line_pad
from .._types import MessageTarget
from .._typing import TypeAlias
from ..scroll_view import ScrollView
from .. import events
NodeID = NewType("NodeID", int)
TreeDataType = TypeVar("TreeDataType")
EventTreeDataType = TypeVar("EventTreeDataType")
LineCacheKey: TypeAlias = "tuple[int | tuple, ...]"
TOGGLE_STYLE = Style.from_meta({"toggle": True})
@dataclass
class _TreeLine:
path: list[TreeNode]
last: bool
@property
def node(self) -> TreeNode:
"""TreeNode: The node associated with this line."""
return self.path[-1]
def _get_guide_width(self, guide_depth: int, show_root: bool) -> 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.
"""
guides = max(0, len(self.path) - (1 if show_root else 2)) * guide_depth
return guides
@rich.repr.auto
class TreeNode(Generic[TreeDataType]):
"""An object that represents a "node" in a tree control."""
def __init__(
self,
tree: Tree[TreeDataType],
parent: TreeNode[TreeDataType] | None,
id: NodeID,
label: Text,
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 = data
self._expanded = expanded
self._children: list[TreeNode] = []
self._hover_ = False
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
yield self.data
def _reset(self) -> None:
self._hover_ = False
self._selected_ = False
self._updates += 1
@property
def line(self) -> int:
"""int: Get the line number for this node, or -1 if it is not displayed."""
return self._line
@property
def _hover(self) -> bool:
"""bool: Check if the mouse is over the node."""
return self._hover_
@_hover.setter
def _hover(self, hover: bool) -> None:
self._updates += 1
self._hover_ = hover
@property
def _selected(self) -> bool:
"""bool: Check if the node is selected."""
return self._selected_
@_selected.setter
def _selected(self, selected: bool) -> None:
self._updates += 1
self._selected_ = selected
@property
def id(self) -> NodeID:
"""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
self._updates += 1
self._tree._invalidate()
def collapse(self) -> None:
"""Collapse the node (hide children)."""
self._expanded = False
self._updates += 1
self._tree._invalidate()
def toggle(self) -> None:
"""Toggle the expanded state."""
self._expanded = not self._expanded
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
def add(
self,
label: TextType,
data: TreeDataType | None = None,
*,
expand: bool = False,
allow_expand: 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.
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
"""
text_label = self._tree.process_label(label)
node = self._tree._add_node(self, text_label, data)
node._expanded = expand
node._allow_expand = allow_expand
self._updates += 1
self._children.append(node)
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):
BINDINGS = [
Binding("enter", "select_cursor", "Select", show=False),
Binding("up", "cursor_up", "Cursor Up", show=False),
Binding("down", "cursor_down", "Cursor Down", show=False),
]
DEFAULT_CSS = """
Tree {
background: $panel;
color: $text;
}
Tree > .tree--label {
}
Tree > .tree--guides {
color: $success-darken-3;
}
Tree > .tree--guides-hover {
color: $success;
text-style: bold;
}
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;
}
Tree > .tree--highlight-line {
background: $boost;
}
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"tree--label",
"tree--guides",
"tree--guides-hover",
"tree--guides-selected",
"tree--cursor",
"tree--highlight",
"tree--highlight-line",
}
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": (
" ",
"",
"└─",
"├─",
),
"bold": (
" ",
"",
"┗━",
"┣━",
),
"double": (
" ",
"",
"╚═",
"╠═",
),
}
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is selected."""
def __init__(
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
) -> None:
self.node = node
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:
self.node = node
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:
self.node = node
super().__init__(sender)
def __init__(
self,
label: TextType,
data: TreeDataType | None = None,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
text_label = self.process_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._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:
"""TreeNode | Node: The currently selected node, or ``None`` if no selection."""
return self._cursor_node
@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):
"""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.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 | None,
expand: bool = False,
) -> TreeNode[TreeDataType]:
node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand)
self._nodes[node._id] = node
self._updates += 1
return node
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:
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.
"""
node_label = node._label.copy()
node_label.stylize(style)
if node._allow_expand:
prefix = (
"" if node.is_expanded else "",
base_style + TOGGLE_STYLE,
)
else:
prefix = ("", base_style)
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
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._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.
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 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:
"""Invalidate caches."""
self._line_cache.clear()
self._tree_lines_cached = None
self._updates += 1
self.root._reset()
self.refresh(layout=True)
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 _new_id(self) -> NodeID:
"""Create a new node ID.
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
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
def watch_guide_depth(self, guide_depth: int) -> None:
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:
"""Scroll to the given line.
Args:
line (int): A line number.
"""
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.
Args:
line (int): Line number.
"""
region = Region(0, line - self.scroll_offset.y, self.size.width, 1)
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:
self._build()
assert self._tree_lines_cached is not None
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
root = self.root
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):
add_node(child_path, child, last)
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
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([get_line_width(line) for line in lines])
else:
width = self.size.width
self.virtual_size = Size(width, len(lines))
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]:
width = self.size.width
scroll_x, scroll_y = self.scroll_offset
style = self.rich_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
) -> list[Segment]:
tree_lines = self._tree_lines
width = self.size.width
if y >= len(tree_lines):
return [Segment(" " * width, base_style)]
line = tree_lines[y]
is_hover = self.hover_line >= 0 and any(node._hover for node in line.path)
cache_key = (
y,
is_hover,
width,
self._updates,
self.has_focus,
tuple(node._updates for node in line.path),
)
if cache_key in self._line_cache:
segments = self._line_cache[cache_key]
else:
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.
"""
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.is_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))
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, event: events.Resize) -> None:
self._line_cache.grow(event.size.height)
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))
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 and self.auto_expand:
self._toggle_node(node)
else:
self.cursor_line = cursor_line
await self.action("select_cursor")
def _on_styles_updated(self) -> None:
self._invalidate()
def action_cursor_up(self) -> None:
if self.cursor_line == -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
else:
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]
except IndexError:
pass
else:
node = line.path[-1]
if self.auto_expand:
self._toggle_node(node)
self.post_message_no_wait(self.NodeSelected(self, node))

View File

@@ -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

View File

@@ -0,0 +1 @@
from ._tree import TreeNode as TreeNode

View File

@@ -6792,6 +6792,162 @@
'''
# ---
# name: test_tree_example
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-1345646321-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-1345646321-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-1345646321-r1 { fill: #e2e3e3 }
.terminal-1345646321-r2 { fill: #c5c8c6 }
.terminal-1345646321-r3 { fill: #008139 }
</style>
<defs>
<clipPath id="terminal-1345646321-clip-terminal">
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
</clipPath>
<clipPath id="terminal-1345646321-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-19">
<rect x="0" y="465.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-20">
<rect x="0" y="489.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-21">
<rect x="0" y="513.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-22">
<rect x="0" y="538.3" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1345646321-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">TreeApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-1345646321-clip-terminal)">
<rect fill="#24292f" x="0" y="1.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="24.4" y="1.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="73.2" y="1.5" width="902.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="25.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="73.2" y="25.9" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="195.2" y="25.9" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="50.3" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="50.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="50.3" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="74.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="74.7" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="74.7" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="99.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="99.1" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="170.8" y="99.1" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-1345646321-matrix">
<text class="terminal-1345646321-r1" x="0" y="20" textLength="24.4" clip-path="url(#terminal-1345646321-line-0)">▼&#160;</text><text class="terminal-1345646321-r1" x="24.4" y="20" textLength="48.8" clip-path="url(#terminal-1345646321-line-0)">Dune</text><text class="terminal-1345646321-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1345646321-line-0)">
</text><text class="terminal-1345646321-r3" x="0" y="44.4" textLength="48.8" clip-path="url(#terminal-1345646321-line-1)">└──&#160;</text><text class="terminal-1345646321-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-1345646321-line-1)">▼&#160;</text><text class="terminal-1345646321-r1" x="73.2" y="44.4" textLength="122" clip-path="url(#terminal-1345646321-line-1)">Characters</text><text class="terminal-1345646321-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-1)">
</text><text class="terminal-1345646321-r3" x="0" y="68.8" textLength="97.6" clip-path="url(#terminal-1345646321-line-2)">&#160;&#160;&#160;&#160;├──&#160;</text><text class="terminal-1345646321-r1" x="97.6" y="68.8" textLength="48.8" clip-path="url(#terminal-1345646321-line-2)">Paul</text><text class="terminal-1345646321-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-2)">
</text><text class="terminal-1345646321-r3" x="0" y="93.2" textLength="97.6" clip-path="url(#terminal-1345646321-line-3)">&#160;&#160;&#160;&#160;├──&#160;</text><text class="terminal-1345646321-r1" x="97.6" y="93.2" textLength="85.4" clip-path="url(#terminal-1345646321-line-3)">Jessica</text><text class="terminal-1345646321-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-3)">
</text><text class="terminal-1345646321-r3" x="0" y="117.6" textLength="97.6" clip-path="url(#terminal-1345646321-line-4)">&#160;&#160;&#160;&#160;└──&#160;</text><text class="terminal-1345646321-r1" x="97.6" y="117.6" textLength="73.2" clip-path="url(#terminal-1345646321-line-4)">Channi</text><text class="terminal-1345646321-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-4)">
</text><text class="terminal-1345646321-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1345646321-line-5)">
</text><text class="terminal-1345646321-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-6)">
</text><text class="terminal-1345646321-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-7)">
</text><text class="terminal-1345646321-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-8)">
</text><text class="terminal-1345646321-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-9)">
</text><text class="terminal-1345646321-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1345646321-line-10)">
</text><text class="terminal-1345646321-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-11)">
</text><text class="terminal-1345646321-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-12)">
</text><text class="terminal-1345646321-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-13)">
</text><text class="terminal-1345646321-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-14)">
</text><text class="terminal-1345646321-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1345646321-line-15)">
</text><text class="terminal-1345646321-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-16)">
</text><text class="terminal-1345646321-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-17)">
</text><text class="terminal-1345646321-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-18)">
</text><text class="terminal-1345646321-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-19)">
</text><text class="terminal-1345646321-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1345646321-line-20)">
</text><text class="terminal-1345646321-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-21)">
</text><text class="terminal-1345646321-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-22)">
</text>
</g>
</g>
</svg>
'''
# ---
# name: test_vertical_layout
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">

View File

@@ -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")