mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
new tree control
This commit is contained in:
1
docs/api/directory_tree.md
Normal file
1
docs/api/directory_tree.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
::: textual.widgets.DirectoryTree
|
||||||
1
docs/api/tree.md
Normal file
1
docs/api/tree.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
::: textual.widgets.Tree
|
||||||
1
docs/api/tree_node.md
Normal file
1
docs/api/tree_node.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
::: textual.widgets.TreeNode
|
||||||
12
docs/examples/widgets/directory_tree.py
Normal file
12
docs/examples/widgets/directory_tree.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import DirectoryTree
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryTreeApp(App):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield DirectoryTree("./")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = DirectoryTreeApp()
|
||||||
|
app.run()
|
||||||
18
docs/examples/widgets/tree.py
Normal file
18
docs/examples/widgets/tree.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Tree
|
||||||
|
|
||||||
|
|
||||||
|
class TreeApp(App):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
tree: Tree = Tree("Dune")
|
||||||
|
tree.root.expand()
|
||||||
|
characters = tree.root.add("Characters", expand=True)
|
||||||
|
characters.add_leaf("Paul")
|
||||||
|
characters.add_leaf("Jessica")
|
||||||
|
characters.add_leaf("Channi")
|
||||||
|
yield tree
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = TreeApp()
|
||||||
|
app.run()
|
||||||
36
docs/widgets/directory_tree.md
Normal file
36
docs/widgets/directory_tree.md
Normal 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
46
docs/widgets/tree.md
Normal 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
|
||||||
@@ -1 +0,0 @@
|
|||||||
# TreeControl
|
|
||||||
79
examples/json_tree.py
Normal file
79
examples/json_tree.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Header, Footer, Tree, TreeNode
|
||||||
|
|
||||||
|
|
||||||
|
class TreeApp(App):
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("a", "add", "Add node"),
|
||||||
|
("c", "clear", "Clear"),
|
||||||
|
("t", "toggle_root", "Toggle root"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Footer()
|
||||||
|
yield Tree("Root")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_json(cls, node: TreeNode, json_data: object) -> None:
|
||||||
|
"""Adds JSON data to a node.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node (TreeNode): A Tree node.
|
||||||
|
json_data (object): An object decoded from JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rich.highlighter import ReprHighlighter
|
||||||
|
|
||||||
|
highlighter = ReprHighlighter()
|
||||||
|
|
||||||
|
def add_node(name: str, node: TreeNode, data: object) -> None:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
node._label = Text(f"{{}} {name}")
|
||||||
|
for key, value in data.items():
|
||||||
|
new_node = node.add("")
|
||||||
|
add_node(key, new_node, value)
|
||||||
|
elif isinstance(data, list):
|
||||||
|
node._label = Text(f"[] {name}")
|
||||||
|
for index, value in enumerate(data):
|
||||||
|
new_node = node.add("")
|
||||||
|
add_node(str(index), new_node, value)
|
||||||
|
else:
|
||||||
|
node._allow_expand = False
|
||||||
|
if name:
|
||||||
|
label = Text.assemble(
|
||||||
|
Text.from_markup(f"[b]{name}[/b]="), highlighter(repr(data))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
label = Text(repr(data))
|
||||||
|
node._label = label
|
||||||
|
|
||||||
|
add_node("JSON", node, json_data)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
with open("food.json") as data_file:
|
||||||
|
self.json_data = json.load(data_file)
|
||||||
|
|
||||||
|
def action_add(self) -> None:
|
||||||
|
tree = self.query_one(Tree)
|
||||||
|
json_node = tree.root.add("JSON")
|
||||||
|
self.add_json(json_node, self.json_data)
|
||||||
|
tree.root.expand()
|
||||||
|
|
||||||
|
def action_clear(self) -> None:
|
||||||
|
tree = self.query_one(Tree)
|
||||||
|
tree.clear()
|
||||||
|
|
||||||
|
def action_toggle_root(self) -> None:
|
||||||
|
tree = self.query_one(Tree)
|
||||||
|
tree.show_root = not tree.show_root
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = TreeApp()
|
||||||
|
app.run()
|
||||||
@@ -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()
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,427 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
from typing import ClassVar, Generic, Iterator, NewType, TypeVar
|
|
||||||
|
|
||||||
import rich.repr
|
|
||||||
from rich.console import RenderableType
|
|
||||||
from rich.style import Style, NULL_STYLE
|
|
||||||
from rich.text import Text, TextType
|
|
||||||
from rich.tree import Tree
|
|
||||||
|
|
||||||
from ..geometry import Region, Size
|
|
||||||
from .. import events
|
|
||||||
from ..reactive import Reactive
|
|
||||||
from .._types import MessageTarget
|
|
||||||
from ..widgets import Static
|
|
||||||
from ..message import Message
|
|
||||||
from .. import messages
|
|
||||||
|
|
||||||
|
|
||||||
NodeID = NewType("NodeID", int)
|
|
||||||
|
|
||||||
|
|
||||||
NodeDataType = TypeVar("NodeDataType")
|
|
||||||
EventNodeDataType = TypeVar("EventNodeDataType")
|
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
|
||||||
class TreeNode(Generic[NodeDataType]):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent: TreeNode[NodeDataType] | None,
|
|
||||||
node_id: NodeID,
|
|
||||||
control: TreeControl,
|
|
||||||
tree: Tree,
|
|
||||||
label: TextType,
|
|
||||||
data: NodeDataType,
|
|
||||||
) -> None:
|
|
||||||
self.parent = parent
|
|
||||||
self.id = node_id
|
|
||||||
self._control = control
|
|
||||||
self._tree = tree
|
|
||||||
self.label = label
|
|
||||||
self.data = data
|
|
||||||
self.loaded = False
|
|
||||||
self._expanded = False
|
|
||||||
self._empty = False
|
|
||||||
self._tree.expanded = False
|
|
||||||
self.children: list[TreeNode] = []
|
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
|
||||||
yield "id", self.id
|
|
||||||
yield "label", self.label
|
|
||||||
yield "data", self.data
|
|
||||||
|
|
||||||
@property
|
|
||||||
def control(self) -> TreeControl:
|
|
||||||
return self._control
|
|
||||||
|
|
||||||
@property
|
|
||||||
def empty(self) -> bool:
|
|
||||||
return self._empty
|
|
||||||
|
|
||||||
@property
|
|
||||||
def expanded(self) -> bool:
|
|
||||||
return self._expanded
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_cursor(self) -> bool:
|
|
||||||
return self.control.cursor == self.id and self.control.show_cursor
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tree(self) -> Tree:
|
|
||||||
return self._tree
|
|
||||||
|
|
||||||
@property
|
|
||||||
def next_node(self) -> TreeNode[NodeDataType] | None:
|
|
||||||
"""The next node in the tree, or None if at the end."""
|
|
||||||
|
|
||||||
if self.expanded and self.children:
|
|
||||||
return self.children[0]
|
|
||||||
else:
|
|
||||||
|
|
||||||
sibling = self.next_sibling
|
|
||||||
if sibling is not None:
|
|
||||||
return sibling
|
|
||||||
|
|
||||||
node = self
|
|
||||||
while True:
|
|
||||||
if node.parent is None:
|
|
||||||
return None
|
|
||||||
sibling = node.parent.next_sibling
|
|
||||||
if sibling is not None:
|
|
||||||
return sibling
|
|
||||||
else:
|
|
||||||
node = node.parent
|
|
||||||
|
|
||||||
@property
|
|
||||||
def previous_node(self) -> TreeNode[NodeDataType] | None:
|
|
||||||
"""The previous node in the tree, or None if at the end."""
|
|
||||||
|
|
||||||
sibling = self.previous_sibling
|
|
||||||
if sibling is not None:
|
|
||||||
|
|
||||||
def last_sibling(node) -> TreeNode[NodeDataType]:
|
|
||||||
if node.expanded and node.children:
|
|
||||||
return last_sibling(node.children[-1])
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
node.children[-1] if (node.children and node.expanded) else node
|
|
||||||
)
|
|
||||||
|
|
||||||
return last_sibling(sibling)
|
|
||||||
|
|
||||||
if self.parent is None:
|
|
||||||
return None
|
|
||||||
return self.parent
|
|
||||||
|
|
||||||
@property
|
|
||||||
def next_sibling(self) -> TreeNode[NodeDataType] | None:
|
|
||||||
"""The next sibling, or None if last sibling."""
|
|
||||||
if self.parent is None:
|
|
||||||
return None
|
|
||||||
iter_siblings = iter(self.parent.children)
|
|
||||||
try:
|
|
||||||
for node in iter_siblings:
|
|
||||||
if node is self:
|
|
||||||
return next(iter_siblings)
|
|
||||||
except StopIteration:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def previous_sibling(self) -> TreeNode[NodeDataType] | None:
|
|
||||||
"""Previous sibling or None if first sibling."""
|
|
||||||
if self.parent is None:
|
|
||||||
return None
|
|
||||||
iter_siblings = iter(self.parent.children)
|
|
||||||
sibling: TreeNode[NodeDataType] | None = None
|
|
||||||
|
|
||||||
for node in iter_siblings:
|
|
||||||
if node is self:
|
|
||||||
return sibling
|
|
||||||
sibling = node
|
|
||||||
return None
|
|
||||||
|
|
||||||
def expand(self, expanded: bool = True) -> None:
|
|
||||||
self._expanded = expanded
|
|
||||||
self._tree.expanded = expanded
|
|
||||||
self._control.refresh(layout=True)
|
|
||||||
|
|
||||||
def toggle(self) -> None:
|
|
||||||
self.expand(not self._expanded)
|
|
||||||
|
|
||||||
def add(self, label: TextType, data: NodeDataType) -> None:
|
|
||||||
self._control.add(self.id, label, data=data)
|
|
||||||
self._control.refresh(layout=True)
|
|
||||||
self._empty = False
|
|
||||||
|
|
||||||
def __rich__(self) -> RenderableType:
|
|
||||||
return self._control.render_node(self)
|
|
||||||
|
|
||||||
|
|
||||||
class TreeControl(Generic[NodeDataType], Static, can_focus=True):
|
|
||||||
DEFAULT_CSS = """
|
|
||||||
TreeControl {
|
|
||||||
color: $text;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
link-style: not underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
TreeControl > .tree--guides {
|
|
||||||
color: $success;
|
|
||||||
}
|
|
||||||
|
|
||||||
TreeControl > .tree--guides-highlight {
|
|
||||||
color: $success;
|
|
||||||
text-style: uu;
|
|
||||||
}
|
|
||||||
|
|
||||||
TreeControl > .tree--guides-cursor {
|
|
||||||
color: $secondary;
|
|
||||||
text-style: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
TreeControl > .tree--labels {
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
TreeControl > .tree--cursor {
|
|
||||||
background: $secondary;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
|
||||||
"tree--guides",
|
|
||||||
"tree--guides-highlight",
|
|
||||||
"tree--guides-cursor",
|
|
||||||
"tree--labels",
|
|
||||||
"tree--cursor",
|
|
||||||
}
|
|
||||||
|
|
||||||
class NodeSelected(Generic[EventNodeDataType], Message, bubble=False):
|
|
||||||
def __init__(
|
|
||||||
self, sender: MessageTarget, node: TreeNode[EventNodeDataType]
|
|
||||||
) -> None:
|
|
||||||
self.node = node
|
|
||||||
super().__init__(sender)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
label: TextType,
|
|
||||||
data: NodeDataType,
|
|
||||||
*,
|
|
||||||
name: str | None = None,
|
|
||||||
id: str | None = None,
|
|
||||||
classes: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(name=name, id=id, classes=classes)
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
self.node_id = NodeID(0)
|
|
||||||
self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {}
|
|
||||||
self._tree = Tree(label)
|
|
||||||
|
|
||||||
self.root: TreeNode[NodeDataType] = TreeNode(
|
|
||||||
None, self.node_id, self, self._tree, label, data
|
|
||||||
)
|
|
||||||
|
|
||||||
self._tree.label = self.root
|
|
||||||
self.nodes[NodeID(self.node_id)] = self.root
|
|
||||||
|
|
||||||
self.auto_links = False
|
|
||||||
|
|
||||||
hover_node: Reactive[NodeID | None] = Reactive(None)
|
|
||||||
cursor: Reactive[NodeID] = Reactive(NodeID(0))
|
|
||||||
cursor_line: Reactive[int] = Reactive(0)
|
|
||||||
show_cursor: Reactive[bool] = Reactive(False)
|
|
||||||
|
|
||||||
def watch_cursor_line(self, value: int) -> None:
|
|
||||||
line_region = Region(0, value, self.size.width, 1)
|
|
||||||
self.emit_no_wait(messages.ScrollToRegion(self, line_region))
|
|
||||||
|
|
||||||
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
|
||||||
def get_size(tree: Tree) -> int:
|
|
||||||
return 1 + sum(
|
|
||||||
get_size(child) if child.expanded else 1 for child in tree.children
|
|
||||||
)
|
|
||||||
|
|
||||||
size = get_size(self._tree)
|
|
||||||
return size
|
|
||||||
|
|
||||||
def add(
|
|
||||||
self,
|
|
||||||
node_id: NodeID,
|
|
||||||
label: TextType,
|
|
||||||
data: NodeDataType,
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
parent = self.nodes[node_id]
|
|
||||||
self.node_id = NodeID(self.node_id + 1)
|
|
||||||
child_tree = parent._tree.add(label)
|
|
||||||
child_tree.guide_style = self._guide_style
|
|
||||||
child_node: TreeNode[NodeDataType] = TreeNode(
|
|
||||||
parent, self.node_id, self, child_tree, label, data
|
|
||||||
)
|
|
||||||
parent.children.append(child_node)
|
|
||||||
child_tree.label = child_node
|
|
||||||
self.nodes[self.node_id] = child_node
|
|
||||||
|
|
||||||
self.refresh(layout=True)
|
|
||||||
|
|
||||||
def find_cursor(self) -> int | None:
|
|
||||||
"""Find the line location for the cursor node."""
|
|
||||||
|
|
||||||
node_id = self.cursor
|
|
||||||
line = 0
|
|
||||||
|
|
||||||
stack: list[Iterator[TreeNode[NodeDataType]]]
|
|
||||||
stack = [iter([self.root])]
|
|
||||||
|
|
||||||
pop = stack.pop
|
|
||||||
push = stack.append
|
|
||||||
while stack:
|
|
||||||
iter_children = pop()
|
|
||||||
try:
|
|
||||||
node = next(iter_children)
|
|
||||||
except StopIteration:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
if node.id == node_id:
|
|
||||||
return line
|
|
||||||
line += 1
|
|
||||||
push(iter_children)
|
|
||||||
if node.children and node.expanded:
|
|
||||||
push(iter(node.children))
|
|
||||||
return None
|
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
|
||||||
guide_style = self._guide_style
|
|
||||||
|
|
||||||
def update_guide_style(tree: Tree) -> None:
|
|
||||||
tree.guide_style = guide_style
|
|
||||||
for child in tree.children:
|
|
||||||
if child.expanded:
|
|
||||||
update_guide_style(child)
|
|
||||||
|
|
||||||
update_guide_style(self._tree)
|
|
||||||
if self.hover_node is not None:
|
|
||||||
hover = self.nodes.get(self.hover_node)
|
|
||||||
if hover is not None:
|
|
||||||
hover._tree.guide_style = self._highlight_guide_style
|
|
||||||
if self.cursor is not None and self.show_cursor:
|
|
||||||
cursor = self.nodes.get(self.cursor)
|
|
||||||
if cursor is not None:
|
|
||||||
cursor._tree.guide_style = self._cursor_guide_style
|
|
||||||
return self._tree
|
|
||||||
|
|
||||||
def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType:
|
|
||||||
label_style = self.get_component_styles("tree--labels").rich_style
|
|
||||||
label = (
|
|
||||||
Text(node.label, no_wrap=True, style=label_style, overflow="ellipsis")
|
|
||||||
if isinstance(node.label, str)
|
|
||||||
else node.label
|
|
||||||
)
|
|
||||||
if node.id == self.hover_node:
|
|
||||||
label.stylize("underline")
|
|
||||||
label.apply_meta({"@click": f"click_label({node.id})", "tree_node": node.id})
|
|
||||||
return label
|
|
||||||
|
|
||||||
def action_click_label(self, node_id: NodeID) -> None:
|
|
||||||
node = self.nodes[node_id]
|
|
||||||
self.cursor = node.id
|
|
||||||
self.cursor_line = self.find_cursor() or 0
|
|
||||||
self.show_cursor = True
|
|
||||||
self.post_message_no_wait(self.NodeSelected(self, node))
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._tree.guide_style = self._guide_style
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _guide_style(self) -> Style:
|
|
||||||
return self.get_component_rich_style("tree--guides")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _highlight_guide_style(self) -> Style:
|
|
||||||
return self.get_component_rich_style("tree--guides-highlight")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _cursor_guide_style(self) -> Style:
|
|
||||||
return self.get_component_rich_style("tree--guides-cursor")
|
|
||||||
|
|
||||||
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
||||||
self.hover_node = event.style.meta.get("tree_node")
|
|
||||||
|
|
||||||
def key_down(self, event: events.Key) -> None:
|
|
||||||
event.stop()
|
|
||||||
self.cursor_down()
|
|
||||||
|
|
||||||
def key_up(self, event: events.Key) -> None:
|
|
||||||
event.stop()
|
|
||||||
self.cursor_up()
|
|
||||||
|
|
||||||
def key_pagedown(self) -> None:
|
|
||||||
assert self.parent is not None
|
|
||||||
height = self.container_viewport.height
|
|
||||||
|
|
||||||
cursor = self.cursor
|
|
||||||
cursor_line = self.cursor_line
|
|
||||||
for _ in range(height):
|
|
||||||
cursor_node = self.nodes[cursor]
|
|
||||||
next_node = cursor_node.next_node
|
|
||||||
if next_node is not None:
|
|
||||||
cursor_line += 1
|
|
||||||
cursor = next_node.id
|
|
||||||
self.cursor = cursor
|
|
||||||
self.cursor_line = cursor_line
|
|
||||||
|
|
||||||
def key_pageup(self) -> None:
|
|
||||||
assert self.parent is not None
|
|
||||||
height = self.container_viewport.height
|
|
||||||
cursor = self.cursor
|
|
||||||
cursor_line = self.cursor_line
|
|
||||||
for _ in range(height):
|
|
||||||
cursor_node = self.nodes[cursor]
|
|
||||||
previous_node = cursor_node.previous_node
|
|
||||||
if previous_node is not None:
|
|
||||||
cursor_line -= 1
|
|
||||||
cursor = previous_node.id
|
|
||||||
self.cursor = cursor
|
|
||||||
self.cursor_line = cursor_line
|
|
||||||
|
|
||||||
def key_home(self) -> None:
|
|
||||||
self.cursor_line = 0
|
|
||||||
self.cursor = NodeID(0)
|
|
||||||
|
|
||||||
def key_end(self) -> None:
|
|
||||||
self.cursor = self.nodes[NodeID(0)].children[-1].id
|
|
||||||
self.cursor_line = self.find_cursor() or 0
|
|
||||||
|
|
||||||
def key_enter(self, event: events.Key) -> None:
|
|
||||||
cursor_node = self.nodes[self.cursor]
|
|
||||||
event.stop()
|
|
||||||
self.post_message_no_wait(self.NodeSelected(self, cursor_node))
|
|
||||||
|
|
||||||
def cursor_down(self) -> None:
|
|
||||||
if not self.show_cursor:
|
|
||||||
self.show_cursor = True
|
|
||||||
return
|
|
||||||
cursor_node = self.nodes[self.cursor]
|
|
||||||
next_node = cursor_node.next_node
|
|
||||||
if next_node is not None:
|
|
||||||
self.cursor_line += 1
|
|
||||||
self.cursor = next_node.id
|
|
||||||
|
|
||||||
def cursor_up(self) -> None:
|
|
||||||
if not self.show_cursor:
|
|
||||||
self.show_cursor = True
|
|
||||||
return
|
|
||||||
cursor_node = self.nodes[self.cursor]
|
|
||||||
previous_node = cursor_node.previous_node
|
|
||||||
if previous_node is not None:
|
|
||||||
self.cursor_line -= 1
|
|
||||||
self.cursor = previous_node.id
|
|
||||||
1
src/textual/widgets/_tree_node.py
Normal file
1
src/textual/widgets/_tree_node.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from ._tree import TreeNode as TreeNode
|
||||||
@@ -6792,6 +6792,162 @@
|
|||||||
|
|
||||||
'''
|
'''
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_tree_example
|
||||||
|
'''
|
||||||
|
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Generated with Rich https://www.textualize.io -->
|
||||||
|
<style>
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fira Code";
|
||||||
|
src: local("FiraCode-Regular"),
|
||||||
|
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||||
|
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fira Code";
|
||||||
|
src: local("FiraCode-Bold"),
|
||||||
|
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||||
|
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||||
|
font-style: bold;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-1345646321-matrix {
|
||||||
|
font-family: Fira Code, monospace;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 24.4px;
|
||||||
|
font-variant-east-asian: full-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-1345646321-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: arial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-1345646321-r1 { fill: #e2e3e3 }
|
||||||
|
.terminal-1345646321-r2 { fill: #c5c8c6 }
|
||||||
|
.terminal-1345646321-r3 { fill: #008139 }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<clipPath id="terminal-1345646321-clip-terminal">
|
||||||
|
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-0">
|
||||||
|
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-1">
|
||||||
|
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-2">
|
||||||
|
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-3">
|
||||||
|
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-4">
|
||||||
|
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-5">
|
||||||
|
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-6">
|
||||||
|
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-7">
|
||||||
|
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-8">
|
||||||
|
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-9">
|
||||||
|
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-10">
|
||||||
|
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-11">
|
||||||
|
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-12">
|
||||||
|
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-13">
|
||||||
|
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-14">
|
||||||
|
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-15">
|
||||||
|
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-16">
|
||||||
|
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-17">
|
||||||
|
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-18">
|
||||||
|
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-19">
|
||||||
|
<rect x="0" y="465.1" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-20">
|
||||||
|
<rect x="0" y="489.5" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-21">
|
||||||
|
<rect x="0" y="513.9" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="terminal-1345646321-line-22">
|
||||||
|
<rect x="0" y="538.3" width="976" height="24.65"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1345646321-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">TreeApp</text>
|
||||||
|
<g transform="translate(26,22)">
|
||||||
|
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||||
|
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||||
|
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(9, 41)" clip-path="url(#terminal-1345646321-clip-terminal)">
|
||||||
|
<rect fill="#24292f" x="0" y="1.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="24.4" y="1.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="73.2" y="1.5" width="902.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="25.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="73.2" y="25.9" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="195.2" y="25.9" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="50.3" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="50.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="50.3" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="74.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="74.7" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="74.7" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="99.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="99.1" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="170.8" y="99.1" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||||
|
<g class="terminal-1345646321-matrix">
|
||||||
|
<text class="terminal-1345646321-r1" x="0" y="20" textLength="24.4" clip-path="url(#terminal-1345646321-line-0)">▼ </text><text class="terminal-1345646321-r1" x="24.4" y="20" textLength="48.8" clip-path="url(#terminal-1345646321-line-0)">Dune</text><text class="terminal-1345646321-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1345646321-line-0)">
|
||||||
|
</text><text class="terminal-1345646321-r3" x="0" y="44.4" textLength="48.8" clip-path="url(#terminal-1345646321-line-1)">└── </text><text class="terminal-1345646321-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-1345646321-line-1)">▼ </text><text class="terminal-1345646321-r1" x="73.2" y="44.4" textLength="122" clip-path="url(#terminal-1345646321-line-1)">Characters</text><text class="terminal-1345646321-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-1)">
|
||||||
|
</text><text class="terminal-1345646321-r3" x="0" y="68.8" textLength="97.6" clip-path="url(#terminal-1345646321-line-2)">    ├── </text><text class="terminal-1345646321-r1" x="97.6" y="68.8" textLength="48.8" clip-path="url(#terminal-1345646321-line-2)">Paul</text><text class="terminal-1345646321-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-2)">
|
||||||
|
</text><text class="terminal-1345646321-r3" x="0" y="93.2" textLength="97.6" clip-path="url(#terminal-1345646321-line-3)">    ├── </text><text class="terminal-1345646321-r1" x="97.6" y="93.2" textLength="85.4" clip-path="url(#terminal-1345646321-line-3)">Jessica</text><text class="terminal-1345646321-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-3)">
|
||||||
|
</text><text class="terminal-1345646321-r3" x="0" y="117.6" textLength="97.6" clip-path="url(#terminal-1345646321-line-4)">    └── </text><text class="terminal-1345646321-r1" x="97.6" y="117.6" textLength="73.2" clip-path="url(#terminal-1345646321-line-4)">Channi</text><text class="terminal-1345646321-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-4)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1345646321-line-5)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-6)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-7)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-8)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-9)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1345646321-line-10)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-11)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-12)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-13)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-14)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1345646321-line-15)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-16)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-17)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-18)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-19)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1345646321-line-20)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-21)">
|
||||||
|
</text><text class="terminal-1345646321-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-22)">
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
'''
|
||||||
|
# ---
|
||||||
# name: test_vertical_layout
|
# 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">
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user