new tree control

This commit is contained in:
Will McGugan
2022-11-20 15:42:35 +00:00
parent 979d85e5d6
commit f7dade5a26
18 changed files with 418 additions and 542 deletions

View File

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

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

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

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

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

View File

@@ -0,0 +1,12 @@
from textual.app import App, ComposeResult
from textual.widgets import DirectoryTree
class DirectoryTreeApp(App):
def compose(self) -> ComposeResult:
yield DirectoryTree("./")
if __name__ == "__main__":
app = DirectoryTreeApp()
app.run()

View File

@@ -0,0 +1,18 @@
from textual.app import App, ComposeResult
from textual.widgets import Tree
class TreeApp(App):
def compose(self) -> ComposeResult:
tree: Tree = Tree("Dune")
tree.root.expand()
characters = tree.root.add("Characters", expand=True)
characters.add_leaf("Paul")
characters.add_leaf("Jessica")
characters.add_leaf("Channi")
yield tree
if __name__ == "__main__":
app = TreeApp()
app.run()

View File

@@ -0,0 +1,36 @@
# DirectoryTree
A tree control to navigate the contents of your filesystem.
- [x] Focusable
- [ ] Container
## Example
The example below creates a simple tree to navigate the current working directory.
```python
--8<-- "docs/examples/widgets/directory_tree.py"
```
## Events
| Event | Default handler | Description |
| ------------------- | --------------------------------- | --------------------------------------- |
| `Tree.FileSelected` | `on_directory_tree_file_selected` | Sent when the user selects a file node. |
## Reactive Attributes
| Name | Type | Default | Description |
| ------------- | ------ | ------- | ----------------------------------------------- |
| `show_root` | `bool` | `True` | Show the root node. |
| `show_guides` | `bool` | `True` | Show guide lines between levels. |
| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
## See Also
* [Tree][textual.widgets.DirectoryTree] code reference
* [Tree][textual.widgets.Tree] code reference

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

@@ -0,0 +1,46 @@
# Tree
A tree control widget.
- [x] Focusable
- [ ] Container
## Example
The example below creates a simple tree.
=== "Output"
```{.textual path="docs/examples/widgets/tree.py"}
```
=== "tree.py"
```python
--8<-- "docs/examples/widgets/tree.py"
```
A each tree widget has a "root" attribute which is an instance of a [TreeNode][textual.widgets.TreeNode]. Call [add()][textual.widgets.TreeNode.add] or [add_leaf()][textual.widgets.TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child, so you can add more levels.
## Events
| Event | Default handler | Description |
| -------------------- | ------------------------ | ------------------------------------------------ |
| `Tree.NodeSelected` | `on_tree_node_selected` | Sent when the user selects a tree node. |
| `Tree.NodeExpanded` | `on_tree_node_expanded` | Sent when the user expands a node in the tree. |
| `Tree.NodeCollapsed` | `on_tree_node_collapsed` | Sent when the user collapsed a node in the tree. |
## Reactive Attributes
| Name | Type | Default | Description |
| ------------- | ------ | ------- | ----------------------------------------------- |
| `show_root` | `bool` | `True` | Show the root node. |
| `show_guides` | `bool` | `True` | Show guide lines between levels. |
| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
## See Also
* [Tree][textual.widgets.Tree] code reference
* [TreeNode][textual.widgets.TreeNode] code reference

View File

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

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

@@ -1,46 +0,0 @@
import json
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Tree, DirectoryTree
with open("food.json") as data_file:
data = json.load(data_file)
from rich import print
print(data)
class TreeApp(App):
BINDINGS = [
("a", "add", "Add node"),
("c", "clear", "Clear"),
("t", "toggle_root", "Toggle root"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Footer()
yield DirectoryTree("../")
def action_add(self) -> None:
tree = self.query_one(Tree)
json_node = tree.root.add("JSON")
tree.root.expand()
tree.add_json(json_node, data)
def action_clear(self) -> None:
tree = self.query_one(Tree)
tree.clear()
def action_toggle_root(self) -> None:
tree = self.query_one(Tree)
tree.show_root = not tree.show_root
if __name__ == "__main__":
app = TreeApp()
app.run()

View File

@@ -89,16 +89,17 @@ nav:
- "styles/visibility.md" - "styles/visibility.md"
- "styles/width.md" - "styles/width.md"
- Widgets: - Widgets:
- "widgets/index.md"
- "widgets/button.md" - "widgets/button.md"
- "widgets/checkbox.md" - "widgets/checkbox.md"
- "widgets/data_table.md" - "widgets/data_table.md"
- "widgets/directory_tree.md"
- "widgets/footer.md" - "widgets/footer.md"
- "widgets/header.md" - "widgets/header.md"
- "widgets/index.md"
- "widgets/input.md" - "widgets/input.md"
- "widgets/label.md" - "widgets/label.md"
- "widgets/static.md" - "widgets/static.md"
- "widgets/tree_control.md" - "widgets/tree.md"
- API: - API:
- "api/index.md" - "api/index.md"
- "api/app.md" - "api/app.md"

View File

@@ -8,22 +8,23 @@ from ..case import camel_to_snake
# but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't # but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't
# be able to "see" them. # be able to "see" them.
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from ..widget import Widget
from ._button import Button from ._button import Button
from ._checkbox import Checkbox from ._checkbox import Checkbox
from ._data_table import DataTable from ._data_table import DataTable
from ._directory_tree import DirectoryTree from ._directory_tree import DirectoryTree
from ._footer import Footer from ._footer import Footer
from ._header import Header from ._header import Header
from ._input import Input
from ._label import Label from ._label import Label
from ._placeholder import Placeholder from ._placeholder import Placeholder
from ._pretty import Pretty from ._pretty import Pretty
from ._static import Static from ._static import Static
from ._input import Input
from ._text_log import TextLog from ._text_log import TextLog
from ._tree import Tree from ._tree import Tree
from ._tree_control import TreeControl from ._tree_node import TreeNode
from ._welcome import Welcome from ._welcome import Welcome
from ..widget import Widget
__all__ = [ __all__ = [
"Button", "Button",
@@ -39,7 +40,7 @@ __all__ = [
"Static", "Static",
"TextLog", "TextLog",
"Tree", "Tree",
"TreeControl", "TreeNode",
"Welcome", "Welcome",
] ]

View File

@@ -12,5 +12,5 @@ from ._static import Static as Static
from ._input import Input as Input from ._input import Input as Input
from ._text_log import TextLog as TextLog from ._text_log import TextLog as TextLog
from ._tree import Tree as Tree from ._tree import Tree as Tree
from ._tree_control import TreeControl as TreeControl from ._tree_node import TreeNode as TreeNode
from ._welcome import Welcome as Welcome from ._welcome import Welcome as Welcome

View File

@@ -56,6 +56,8 @@ class _TreeLine:
@rich.repr.auto @rich.repr.auto
class TreeNode(Generic[TreeDataType]): class TreeNode(Generic[TreeDataType]):
"""An object that represents a "node" in a tree control."""
def __init__( def __init__(
self, self,
tree: Tree[TreeDataType], tree: Tree[TreeDataType],
@@ -90,8 +92,9 @@ class TreeNode(Generic[TreeDataType]):
self._selected_ = False self._selected_ = False
self._updates += 1 self._updates += 1
@property
def line(self) -> int: def line(self) -> int:
"""Get the line number for this node, or -1 if it is not displayed.""" """int: Get the line number for this node, or -1 if it is not displayed."""
return self._line return self._line
@property @property
@@ -116,9 +119,33 @@ class TreeNode(Generic[TreeDataType]):
@property @property
def id(self) -> NodeID: def id(self) -> NodeID:
"""Get the node ID.""" """NodeID: Get the node ID."""
return self._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: def expand(self) -> None:
"""Expand a node (show its children).""" """Expand a node (show its children)."""
self._expanded = True self._expanded = True
@@ -147,30 +174,6 @@ class TreeNode(Generic[TreeDataType]):
text_label = self._tree.process_label(label) text_label = self._tree.process_label(label)
self._label = text_label self._label = text_label
@property
def is_expanded(self) -> bool:
"""bool: Check if the node is expanded."""
return self._expanded
@property
def is_last(self) -> bool:
"""bool: Check if this is the last child."""
if self._parent is None:
return True
return bool(
self._parent._children and self._parent._children[-1] == self,
)
@property
def allow_expand(self) -> bool:
"""bool: Check if the node is allowed to expand."""
return self._allow_expand
@allow_expand.setter
def allow_expand(self, allow_expand: bool) -> bool:
self._allow_expand = allow_expand
self._updates += 1
def add( def add(
self, self,
label: TextType, label: TextType,
@@ -199,6 +202,21 @@ class TreeNode(Generic[TreeDataType]):
self._tree._invalidate() self._tree._invalidate()
return node 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): class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
@@ -451,36 +469,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
else: else:
return line.node return line.node
def add_json(self, node: TreeNode, json_data: object) -> None:
from rich.highlighter import ReprHighlighter
highlighter = ReprHighlighter()
def add_node(name: str, node: TreeNode, data: object) -> None:
if isinstance(data, dict):
node._label = Text(f"{{}} {name}")
for key, value in data.items():
new_node = node.add("")
add_node(key, new_node, value)
elif isinstance(data, list):
node._label = Text(f"[] {name}")
for index, value in enumerate(data):
new_node = node.add("")
add_node(str(index), new_node, value)
else:
node._allow_expand = False
if name:
label = Text.assemble(
Text.from_markup(f"[b]{name}[/b]="), highlighter(repr(data))
)
else:
label = Text(repr(data))
node._label = label
add_node("JSON", node, json_data)
self._invalidate()
def validate_cursor_line(self, value: int) -> int: def validate_cursor_line(self, value: int) -> int:
return clamp(value, 0, len(self._tree_lines) - 1) return clamp(value, 0, len(self._tree_lines) - 1)
@@ -642,10 +630,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
width = self.size.width width = self.size.width
self.virtual_size = Size(width, len(lines)) self.virtual_size = Size(width, len(lines))
if self.cursor_node is not None: if self.cursor_line != -1:
self.cursor_line = self.cursor_node._line if self.cursor_node is not None:
if self.cursor_line >= len(lines): self.cursor_line = self.cursor_node._line
self.cursor_line = -1 if self.cursor_line >= len(lines):
self.cursor_line = -1
self.refresh() self.refresh()
def render_line(self, y: int) -> list[Segment]: def render_line(self, y: int) -> list[Segment]:
@@ -813,7 +802,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
def action_cursor_down(self) -> None: def action_cursor_down(self) -> None:
if self.cursor_line == -1: if self.cursor_line == -1:
self.cursor_line = 0 self.cursor_line = 0
self.cursor_line += 1 else:
self.cursor_line += 1
self.scroll_to_line(self.cursor_line) self.scroll_to_line(self.cursor_line)
def action_page_down(self) -> None: def action_page_down(self) -> None:

View File

@@ -1,427 +0,0 @@
from __future__ import annotations
from typing import ClassVar, Generic, Iterator, NewType, TypeVar
import rich.repr
from rich.console import RenderableType
from rich.style import Style, NULL_STYLE
from rich.text import Text, TextType
from rich.tree import Tree
from ..geometry import Region, Size
from .. import events
from ..reactive import Reactive
from .._types import MessageTarget
from ..widgets import Static
from ..message import Message
from .. import messages
NodeID = NewType("NodeID", int)
NodeDataType = TypeVar("NodeDataType")
EventNodeDataType = TypeVar("EventNodeDataType")
@rich.repr.auto
class TreeNode(Generic[NodeDataType]):
def __init__(
self,
parent: TreeNode[NodeDataType] | None,
node_id: NodeID,
control: TreeControl,
tree: Tree,
label: TextType,
data: NodeDataType,
) -> None:
self.parent = parent
self.id = node_id
self._control = control
self._tree = tree
self.label = label
self.data = data
self.loaded = False
self._expanded = False
self._empty = False
self._tree.expanded = False
self.children: list[TreeNode] = []
def __rich_repr__(self) -> rich.repr.Result:
yield "id", self.id
yield "label", self.label
yield "data", self.data
@property
def control(self) -> TreeControl:
return self._control
@property
def empty(self) -> bool:
return self._empty
@property
def expanded(self) -> bool:
return self._expanded
@property
def is_cursor(self) -> bool:
return self.control.cursor == self.id and self.control.show_cursor
@property
def tree(self) -> Tree:
return self._tree
@property
def next_node(self) -> TreeNode[NodeDataType] | None:
"""The next node in the tree, or None if at the end."""
if self.expanded and self.children:
return self.children[0]
else:
sibling = self.next_sibling
if sibling is not None:
return sibling
node = self
while True:
if node.parent is None:
return None
sibling = node.parent.next_sibling
if sibling is not None:
return sibling
else:
node = node.parent
@property
def previous_node(self) -> TreeNode[NodeDataType] | None:
"""The previous node in the tree, or None if at the end."""
sibling = self.previous_sibling
if sibling is not None:
def last_sibling(node) -> TreeNode[NodeDataType]:
if node.expanded and node.children:
return last_sibling(node.children[-1])
else:
return (
node.children[-1] if (node.children and node.expanded) else node
)
return last_sibling(sibling)
if self.parent is None:
return None
return self.parent
@property
def next_sibling(self) -> TreeNode[NodeDataType] | None:
"""The next sibling, or None if last sibling."""
if self.parent is None:
return None
iter_siblings = iter(self.parent.children)
try:
for node in iter_siblings:
if node is self:
return next(iter_siblings)
except StopIteration:
pass
return None
@property
def previous_sibling(self) -> TreeNode[NodeDataType] | None:
"""Previous sibling or None if first sibling."""
if self.parent is None:
return None
iter_siblings = iter(self.parent.children)
sibling: TreeNode[NodeDataType] | None = None
for node in iter_siblings:
if node is self:
return sibling
sibling = node
return None
def expand(self, expanded: bool = True) -> None:
self._expanded = expanded
self._tree.expanded = expanded
self._control.refresh(layout=True)
def toggle(self) -> None:
self.expand(not self._expanded)
def add(self, label: TextType, data: NodeDataType) -> None:
self._control.add(self.id, label, data=data)
self._control.refresh(layout=True)
self._empty = False
def __rich__(self) -> RenderableType:
return self._control.render_node(self)
class TreeControl(Generic[NodeDataType], Static, can_focus=True):
DEFAULT_CSS = """
TreeControl {
color: $text;
height: auto;
width: 100%;
link-style: not underline;
}
TreeControl > .tree--guides {
color: $success;
}
TreeControl > .tree--guides-highlight {
color: $success;
text-style: uu;
}
TreeControl > .tree--guides-cursor {
color: $secondary;
text-style: bold;
}
TreeControl > .tree--labels {
color: $text;
}
TreeControl > .tree--cursor {
background: $secondary;
color: $text;
}
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"tree--guides",
"tree--guides-highlight",
"tree--guides-cursor",
"tree--labels",
"tree--cursor",
}
class NodeSelected(Generic[EventNodeDataType], Message, bubble=False):
def __init__(
self, sender: MessageTarget, node: TreeNode[EventNodeDataType]
) -> None:
self.node = node
super().__init__(sender)
def __init__(
self,
label: TextType,
data: NodeDataType,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self.data = data
self.node_id = NodeID(0)
self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {}
self._tree = Tree(label)
self.root: TreeNode[NodeDataType] = TreeNode(
None, self.node_id, self, self._tree, label, data
)
self._tree.label = self.root
self.nodes[NodeID(self.node_id)] = self.root
self.auto_links = False
hover_node: Reactive[NodeID | None] = Reactive(None)
cursor: Reactive[NodeID] = Reactive(NodeID(0))
cursor_line: Reactive[int] = Reactive(0)
show_cursor: Reactive[bool] = Reactive(False)
def watch_cursor_line(self, value: int) -> None:
line_region = Region(0, value, self.size.width, 1)
self.emit_no_wait(messages.ScrollToRegion(self, line_region))
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
def get_size(tree: Tree) -> int:
return 1 + sum(
get_size(child) if child.expanded else 1 for child in tree.children
)
size = get_size(self._tree)
return size
def add(
self,
node_id: NodeID,
label: TextType,
data: NodeDataType,
) -> None:
parent = self.nodes[node_id]
self.node_id = NodeID(self.node_id + 1)
child_tree = parent._tree.add(label)
child_tree.guide_style = self._guide_style
child_node: TreeNode[NodeDataType] = TreeNode(
parent, self.node_id, self, child_tree, label, data
)
parent.children.append(child_node)
child_tree.label = child_node
self.nodes[self.node_id] = child_node
self.refresh(layout=True)
def find_cursor(self) -> int | None:
"""Find the line location for the cursor node."""
node_id = self.cursor
line = 0
stack: list[Iterator[TreeNode[NodeDataType]]]
stack = [iter([self.root])]
pop = stack.pop
push = stack.append
while stack:
iter_children = pop()
try:
node = next(iter_children)
except StopIteration:
continue
else:
if node.id == node_id:
return line
line += 1
push(iter_children)
if node.children and node.expanded:
push(iter(node.children))
return None
def render(self) -> RenderableType:
guide_style = self._guide_style
def update_guide_style(tree: Tree) -> None:
tree.guide_style = guide_style
for child in tree.children:
if child.expanded:
update_guide_style(child)
update_guide_style(self._tree)
if self.hover_node is not None:
hover = self.nodes.get(self.hover_node)
if hover is not None:
hover._tree.guide_style = self._highlight_guide_style
if self.cursor is not None and self.show_cursor:
cursor = self.nodes.get(self.cursor)
if cursor is not None:
cursor._tree.guide_style = self._cursor_guide_style
return self._tree
def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType:
label_style = self.get_component_styles("tree--labels").rich_style
label = (
Text(node.label, no_wrap=True, style=label_style, overflow="ellipsis")
if isinstance(node.label, str)
else node.label
)
if node.id == self.hover_node:
label.stylize("underline")
label.apply_meta({"@click": f"click_label({node.id})", "tree_node": node.id})
return label
def action_click_label(self, node_id: NodeID) -> None:
node = self.nodes[node_id]
self.cursor = node.id
self.cursor_line = self.find_cursor() or 0
self.show_cursor = True
self.post_message_no_wait(self.NodeSelected(self, node))
def on_mount(self) -> None:
self._tree.guide_style = self._guide_style
@property
def _guide_style(self) -> Style:
return self.get_component_rich_style("tree--guides")
@property
def _highlight_guide_style(self) -> Style:
return self.get_component_rich_style("tree--guides-highlight")
@property
def _cursor_guide_style(self) -> Style:
return self.get_component_rich_style("tree--guides-cursor")
def on_mouse_move(self, event: events.MouseMove) -> None:
self.hover_node = event.style.meta.get("tree_node")
def key_down(self, event: events.Key) -> None:
event.stop()
self.cursor_down()
def key_up(self, event: events.Key) -> None:
event.stop()
self.cursor_up()
def key_pagedown(self) -> None:
assert self.parent is not None
height = self.container_viewport.height
cursor = self.cursor
cursor_line = self.cursor_line
for _ in range(height):
cursor_node = self.nodes[cursor]
next_node = cursor_node.next_node
if next_node is not None:
cursor_line += 1
cursor = next_node.id
self.cursor = cursor
self.cursor_line = cursor_line
def key_pageup(self) -> None:
assert self.parent is not None
height = self.container_viewport.height
cursor = self.cursor
cursor_line = self.cursor_line
for _ in range(height):
cursor_node = self.nodes[cursor]
previous_node = cursor_node.previous_node
if previous_node is not None:
cursor_line -= 1
cursor = previous_node.id
self.cursor = cursor
self.cursor_line = cursor_line
def key_home(self) -> None:
self.cursor_line = 0
self.cursor = NodeID(0)
def key_end(self) -> None:
self.cursor = self.nodes[NodeID(0)].children[-1].id
self.cursor_line = self.find_cursor() or 0
def key_enter(self, event: events.Key) -> None:
cursor_node = self.nodes[self.cursor]
event.stop()
self.post_message_no_wait(self.NodeSelected(self, cursor_node))
def cursor_down(self) -> None:
if not self.show_cursor:
self.show_cursor = True
return
cursor_node = self.nodes[self.cursor]
next_node = cursor_node.next_node
if next_node is not None:
self.cursor_line += 1
self.cursor = next_node.id
def cursor_up(self) -> None:
if not self.show_cursor:
self.show_cursor = True
return
cursor_node = self.nodes[self.cursor]
previous_node = cursor_node.previous_node
if previous_node is not None:
self.cursor_line -= 1
self.cursor = previous_node.id

View File

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

View File

@@ -6792,6 +6792,162 @@
''' '''
# --- # ---
# name: test_tree_example
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-1345646321-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-1345646321-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-1345646321-r1 { fill: #e2e3e3 }
.terminal-1345646321-r2 { fill: #c5c8c6 }
.terminal-1345646321-r3 { fill: #008139 }
</style>
<defs>
<clipPath id="terminal-1345646321-clip-terminal">
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
</clipPath>
<clipPath id="terminal-1345646321-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-19">
<rect x="0" y="465.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-20">
<rect x="0" y="489.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-21">
<rect x="0" y="513.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1345646321-line-22">
<rect x="0" y="538.3" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1345646321-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">TreeApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-1345646321-clip-terminal)">
<rect fill="#24292f" x="0" y="1.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="24.4" y="1.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="73.2" y="1.5" width="902.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="25.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="73.2" y="25.9" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="195.2" y="25.9" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="50.3" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="50.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="50.3" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="74.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="74.7" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="74.7" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="99.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="99.1" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="170.8" y="99.1" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-1345646321-matrix">
<text class="terminal-1345646321-r1" x="0" y="20" textLength="24.4" clip-path="url(#terminal-1345646321-line-0)">▼&#160;</text><text class="terminal-1345646321-r1" x="24.4" y="20" textLength="48.8" clip-path="url(#terminal-1345646321-line-0)">Dune</text><text class="terminal-1345646321-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1345646321-line-0)">
</text><text class="terminal-1345646321-r3" x="0" y="44.4" textLength="48.8" clip-path="url(#terminal-1345646321-line-1)">└──&#160;</text><text class="terminal-1345646321-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-1345646321-line-1)">▼&#160;</text><text class="terminal-1345646321-r1" x="73.2" y="44.4" textLength="122" clip-path="url(#terminal-1345646321-line-1)">Characters</text><text class="terminal-1345646321-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-1)">
</text><text class="terminal-1345646321-r3" x="0" y="68.8" textLength="97.6" clip-path="url(#terminal-1345646321-line-2)">&#160;&#160;&#160;&#160;├──&#160;</text><text class="terminal-1345646321-r1" x="97.6" y="68.8" textLength="48.8" clip-path="url(#terminal-1345646321-line-2)">Paul</text><text class="terminal-1345646321-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-2)">
</text><text class="terminal-1345646321-r3" x="0" y="93.2" textLength="97.6" clip-path="url(#terminal-1345646321-line-3)">&#160;&#160;&#160;&#160;├──&#160;</text><text class="terminal-1345646321-r1" x="97.6" y="93.2" textLength="85.4" clip-path="url(#terminal-1345646321-line-3)">Jessica</text><text class="terminal-1345646321-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-3)">
</text><text class="terminal-1345646321-r3" x="0" y="117.6" textLength="97.6" clip-path="url(#terminal-1345646321-line-4)">&#160;&#160;&#160;&#160;└──&#160;</text><text class="terminal-1345646321-r1" x="97.6" y="117.6" textLength="73.2" clip-path="url(#terminal-1345646321-line-4)">Channi</text><text class="terminal-1345646321-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-4)">
</text><text class="terminal-1345646321-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1345646321-line-5)">
</text><text class="terminal-1345646321-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-6)">
</text><text class="terminal-1345646321-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-7)">
</text><text class="terminal-1345646321-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-8)">
</text><text class="terminal-1345646321-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-9)">
</text><text class="terminal-1345646321-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1345646321-line-10)">
</text><text class="terminal-1345646321-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-11)">
</text><text class="terminal-1345646321-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-12)">
</text><text class="terminal-1345646321-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-13)">
</text><text class="terminal-1345646321-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-14)">
</text><text class="terminal-1345646321-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1345646321-line-15)">
</text><text class="terminal-1345646321-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-16)">
</text><text class="terminal-1345646321-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-17)">
</text><text class="terminal-1345646321-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-18)">
</text><text class="terminal-1345646321-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-19)">
</text><text class="terminal-1345646321-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1345646321-line-20)">
</text><text class="terminal-1345646321-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-21)">
</text><text class="terminal-1345646321-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-22)">
</text>
</g>
</g>
</svg>
'''
# ---
# name: test_vertical_layout # name: test_vertical_layout
''' '''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg"> <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 --- # --- Layout related stuff ---
def test_grid_layout_basic(snap_compare): def test_grid_layout_basic(snap_compare):
assert snap_compare(LAYOUT_EXAMPLES_DIR / "grid_layout1.py") 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 # When adding a new widget, ideally we should also create a snapshot test
# from these examples which test rendering and simple interactions with it. # from these examples which test rendering and simple interactions with it.
def test_checkboxes(snap_compare): def test_checkboxes(snap_compare):
"""Tests checkboxes but also acts a regression test for using """Tests checkboxes but also acts a regression test for using
width: auto in a Horizontal layout context.""" 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") assert snap_compare("snapshot_apps/fr_units.py")
def test_tree_example(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "tree.py")
# --- CSS properties --- # --- CSS properties ---
# We have a canonical example for each CSS property that is shown in their docs. # 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. # 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 --- # --- Other ---
def test_key_display(snap_compare): def test_key_display(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py") assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py")