Merge branch 'main' of github.com:willmcgugan/textual into list-view

This commit is contained in:
Darren Burns
2022-11-23 11:03:40 +00:00
33 changed files with 3884 additions and 582 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,233 @@
---
draft: false
date: 2022-11-22
categories:
- DevLog
authors:
- rodrigo
---
# What I learned from my first non-trivial PR
<div>
--8<-- "docs/blog/images/placeholder-example.svg"
</div>
It's 8:59 am and, by my Portuguese standards, it is freezing cold outside: 5 or 6 degrees Celsius.
It is my second day at Textualize and I just got into the office.
I undress my many layers of clothing to protect me from the Scottish cold and I sit down in my improvised corner of the Textualize office.
As I sit down, I turn myself in my chair to face my boss and colleagues to ask “So, what should I do today?”.
I was not expecting Will's answer, but the challenge excited me:
<!-- more -->
> “I thought I'll just throw you in the deep end and have you write some code.”
What happened next was that I spent two days [working on PR #1229](https://github.com/Textualize/textual/pull/1229) to add a new widget to the [Textual](https://github.com/Textualize/textual) code base.
At the time of writing, the pull request has not been merged yet.
Well, to be honest with you, it hasn't even been reviewed by anyone...
But that won't stop me from blogging about some of the things I learned while creating this PR.
## The placeholder widget
This PR adds a widget called `Placeholder` to Textual.
As per the documentation, this widget “is meant to have no complex functionality.
Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.”
The point of the placeholder widget is that you can focus on building the layout of your app without having to have all of your (custom) widgets ready.
The placeholder widget also displays a couple of useful pieces of information to help you work out the layout of your app, namely the ID of the widget itself (or a custom label, if you provide one) and the width and height of the widget.
As an example of usage of the placeholder widget, you can refer to the screenshot at the top of this blog post, which I included below so you don't have to scroll up:
<div>
--8<-- "docs/blog/images/placeholder-example.svg"
</div>
The top left and top right widgets have custom labels.
Immediately under the top right placeholder, you can see some placeholders identified as `#p3`, `#p4`, and `#p5`.
Those are the IDs of the respective placeholders.
Then, rows 2 and 3 contain some placeholders that show their respective size and some placeholders that just contain some text.
## Bootstrapping the code for the widget
So, how does a code monkey start working on a non-trivial PR within 24 hours of joining a company?
The answer is simple: just copy and paste code!
But instead of copying and pasting from Stack Overflow, I decided to copy and paste from the internal code base.
My task was to create a new widget, so I thought it would be a good idea to take a look at the implementation of other Textual widgets.
For some reason I cannot seem to recall, I decided to take a look at the implementation of the button widget that you can find in [_button.py](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_button.py).
By looking at how the button widget is implemented, I could immediately learn a few useful things about what I needed to do and some other things about how Textual works.
For example, a widget can have a class attribute called `DEFAULT_CSS` that specifies the default CSS for that widget.
I learned this just from staring at the code for the button widget.
Studying the code base will also reveal the standards that are in place.
For example, I learned that for a widget with variants (like the button with its “success” and “error” variants), the widget gets a CSS class with the name of the variant prefixed by a dash.
You can learn this by looking at the method `Button.watch_variant`:
```py
class Button(Static, can_focus=True):
# ...
def watch_variant(self, old_variant: str, variant: str):
self.remove_class(f"-{old_variant}")
self.add_class(f"-{variant}")
```
In short, looking at code and files that are related to the things you need to do is a great way to get information about things you didn't even know you needed.
## Handling the placeholder variant
A button widget can have a different variant, which is mostly used by Textual to determine the CSS that should apply to the given button.
For the placeholder widget, we want the variant to determine what information the placeholder shows.
The [original GitHub issue](https://github.com/Textualize/textual/issues/1200) mentions 5 variants for the placeholder:
- a variant that just shows a label or the placeholder ID;
- a variant that shows the size and location of the placeholder;
- a variant that shows the state of the placeholder (does it have focus? is the mouse over it?);
- a variant that shows the CSS that is applied to the placeholder itself; and
- a variant that shows some text inside the placeholder.
The variant can be assigned when the placeholder is first instantiated, for example, `Placeholder("css")` would create a placeholder that shows its own CSS.
However, we also want to have an `on_click` handler that cycles through all the possible variants.
I was getting ready to reinvent the wheel when I remembered that the standard module [`itertools`](https://docs.python.org/3/library/itertools) has a lovely tool that does exactly what I needed!
Thus, all I needed to do was create a new `cycle` through the variants each time a placeholder is created and then grab the next variant whenever the placeholder is clicked:
```py
class Placeholder(Static):
def __init__(
self,
variant: PlaceholderVariant = "default",
*,
label: str | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
# ...
self.variant = self.validate_variant(variant)
# Set a cycle through the variants with the correct starting point.
self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
while next(self._variants_cycle) != self.variant:
pass
def on_click(self) -> None:
"""Click handler to cycle through the placeholder variants."""
self.cycle_variant()
def cycle_variant(self) -> None:
"""Get the next variant in the cycle."""
self.variant = next(self._variants_cycle)
```
I am just happy that I had the insight to add this little `while` loop when a placeholder is instantiated:
```py
from itertools import cycle
# ...
class Placeholder(Static):
# ...
def __init__(...):
# ...
self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
while next(self._variants_cycle) != self.variant:
pass
```
Can you see what would be wrong if this loop wasn't there?
## Updating the render of the placeholder on variant change
If the variant of the placeholder is supposed to determine what information the placeholder shows, then that information must be updated every time the variant of the placeholder changes.
Thankfully, Textual has reactive attributes and watcher methods, so all I needed to do was...
Defer the problem to another method:
```py
class Placeholder(Static):
# ...
variant = reactive("default")
# ...
def watch_variant(
self, old_variant: PlaceholderVariant, variant: PlaceholderVariant
) -> None:
self.validate_variant(variant)
self.remove_class(f"-{old_variant}")
self.add_class(f"-{variant}")
self.call_variant_update() # <-- let this method do the heavy lifting!
```
Doing this properly required some thinking.
Not that the current proposed solution is the best possible, but I did think of worse alternatives while I was thinking how to tackle this.
I wasn't entirely sure how I would manage the variant-dependant rendering because I am not a fan of huge conditional statements that look like switch statements:
```py
if variant == "default":
# render the default placeholder
elif variant == "size":
# render the placeholder with its size
elif variant == "state":
# render the state of the placeholder
elif variant == "css":
# render the placeholder with its CSS rules
elif variant == "text":
# render the placeholder with some text inside
```
However, I am a fan of using the built-in `getattr` and I thought of creating a rendering method for each different variant.
Then, all I needed to do was make sure the variant is part of the name of the method so that I can programmatically determine the name of the method that I need to call.
This means that the method `Placeholder.call_variant_update` is just this:
```py
class Placeholder(Static):
# ...
def call_variant_update(self) -> None:
"""Calls the appropriate method to update the render of the placeholder."""
update_variant_method = getattr(self, f"_update_{self.variant}_variant")
update_variant_method()
```
If `self.variant` is, say, `"size"`, then `update_variant_method` refers to `_update_size_variant`:
```py
class Placeholder(Static):
# ...
def _update_size_variant(self) -> None:
"""Update the placeholder with the size of the placeholder."""
width, height = self.size
self._placeholder_label.update(f"[b]{width} x {height}[/b]")
```
This variant `"size"` also interacts with resizing events, so we have to watch out for those:
```py
class Placeholder(Static):
# ...
def on_resize(self, event: events.Resize) -> None:
"""Update the placeholder "size" variant with the new placeholder size."""
if self.variant == "size":
self._update_size_variant()
```
## Deleting code is a (hurtful) blessing
To conclude this blog post, let me muse about the fact that the original issue mentioned five placeholder variants and that my PR only includes two and a half.
After careful consideration and after coming up with the `getattr` mechanism to update the display of the placeholder according to the active variant, I started showing the “final” product to Will and my other colleagues.
Eventually, we ended up getting rid of the variant for CSS and the variant that shows the placeholder state.
This means that I had to **delete part of my code** even before it saw the light of day.
On the one hand, deleting those chunks of code made me a bit sad.
After all, I had spent quite some time thinking about how to best implement that functionality!
But then, it was time to write documentation and tests, and I verified that the **best code** is the code that you don't even write!
The code you don't write is guaranteed to have zero bugs and it also does not need any documentation whatsoever!
So, it was a shame that some lines of code I poured my heart and keyboard into did not get merged into the Textual code base.
On the other hand, I am quite grateful that I won't have to fix the bugs that will certainly reveal themselves in a couple of weeks or months from now.
Heck, the code hasn't been merged yet and just by writing this blog post I noticed a couple of tweaks that were missing!

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

@@ -5,20 +5,17 @@ Screen {
#tree-view {
display: none;
scrollbar-gutter: stable;
overflow: auto;
width: auto;
height: 100%;
dock: left;
}
CodeBrowser.-show-tree #tree-view {
display: block;
dock: left;
height: 100%;
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,18 +89,19 @@ 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/list_view.md"
- "widgets/list_item.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

@@ -176,6 +176,7 @@ class LinuxDriver(Driver):
self.exit_event.set()
if self._key_thread is not None:
self._key_thread.join()
self.exit_event.clear()
termios.tcflush(self.fileno, termios.TCIFLUSH)
except Exception as error:
# TODO: log this

View File

@@ -84,6 +84,7 @@ class WindowsDriver(Driver):
if self._event_thread is not None:
self._event_thread.join()
self._event_thread = None
self.exit_event.clear()
except Exception as error:
# TODO: log this
pass

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,23 +8,25 @@ 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 ._label import Label
from ._list_view import ListView
from ._list_item import ListItem
from ._pretty import Pretty
from ._placeholder import Placeholder
from ._static import Static
from ._input import Input
from ._label import Label
from ._list_item import ListItem
from ._list_view import ListView
from ._placeholder import Placeholder
from ._pretty import Pretty
from ._static import Static
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",
@@ -33,15 +35,16 @@ __all__ = [
"DirectoryTree",
"Footer",
"Header",
"Input",
"Label",
"ListItem",
"ListView",
"Label",
"Placeholder",
"Pretty",
"Static",
"Input",
"TextLog",
"TreeControl",
"Tree",
"TreeNode",
"Welcome",
]

View File

@@ -13,5 +13,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)
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:
self.emit_no_wait(self.FileSelected(self, dir_entry.path))
async def on_tree_control_node_selected(
self, message: TreeControl.NodeSelected[DirEntry]
) -> None:
dir_entry = message.node.data
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:
await self.emit(self.FileClick(self, dir_entry.path))
else:
if not message.node.loaded:
await self.load_directory(message.node)
message.node.expand()
else:
message.node.toggle()
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

@@ -6950,6 +6950,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."""
@@ -102,6 +104,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.
@@ -126,5 +132,6 @@ def test_multiple_css(snap_compare):
# --- Other ---
def test_key_display(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py")