mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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
|
||||
|
||||
1
docs/api/directory_tree.md
Normal file
1
docs/api/directory_tree.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.DirectoryTree
|
||||
1
docs/api/tree.md
Normal file
1
docs/api/tree.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.Tree
|
||||
1
docs/api/tree_node.md
Normal file
1
docs/api/tree_node.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.TreeNode
|
||||
12
docs/examples/widgets/directory_tree.py
Normal file
12
docs/examples/widgets/directory_tree.py
Normal 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()
|
||||
18
docs/examples/widgets/tree.py
Normal file
18
docs/examples/widgets/tree.py
Normal 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()
|
||||
43
docs/widgets/directory_tree.md
Normal file
43
docs/widgets/directory_tree.md
Normal 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
80
docs/widgets/tree.md
Normal 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
|
||||
@@ -1 +0,0 @@
|
||||
# TreeControl
|
||||
@@ -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;
|
||||
|
||||
@@ -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
1944
examples/food.json
Normal file
File diff suppressed because it is too large
Load Diff
79
examples/json_tree.py
Normal file
79
examples/json_tree.py
Normal 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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
849
src/textual/widgets/_tree.py
Normal file
849
src/textual/widgets/_tree.py
Normal 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))
|
||||
@@ -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
|
||||
1
src/textual/widgets/_tree_node.py
Normal file
1
src/textual/widgets/_tree_node.py
Normal file
@@ -0,0 +1 @@
|
||||
from ._tree import TreeNode as TreeNode
|
||||
@@ -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)">▼ </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)">└── </text><text class="terminal-1345646321-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-1345646321-line-1)">▼ </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)">    ├── </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)">    ├── </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)">    └── </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">
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user