mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
eol
This commit is contained in:
48
examples/code_viewer.py
Normal file
48
examples/code_viewer.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from rich.syntax import Syntax
|
||||
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
from textual.widgets import Header, Footer, FileClick, ScrollView, DirectoryTree
|
||||
|
||||
|
||||
class MyApp(App):
|
||||
"""An example of a very simple Textual App"""
|
||||
|
||||
async def on_load(self, event: events.Load) -> None:
|
||||
await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar")
|
||||
await self.bind("q", "quit", "Quit")
|
||||
|
||||
try:
|
||||
self.path = sys.argv[1]
|
||||
except IndexError:
|
||||
self.path = os.path.abspath(
|
||||
os.path.join(os.path.basename(__file__), "../../")
|
||||
)
|
||||
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
|
||||
self.body = ScrollView()
|
||||
self.directory = DirectoryTree(self.path, "Code")
|
||||
|
||||
await self.view.dock(Header(), edge="top")
|
||||
await self.view.dock(Footer(), edge="bottom")
|
||||
await self.view.dock(self.directory, edge="left", size=32, name="sidebar")
|
||||
await self.view.dock(self.body, edge="right")
|
||||
|
||||
async def message_file_click(self, message: FileClick) -> None:
|
||||
syntax = Syntax.from_path(
|
||||
message.path,
|
||||
line_numbers=True,
|
||||
word_wrap=True,
|
||||
indent_guides=True,
|
||||
theme="monokai",
|
||||
)
|
||||
self.app.sub_title = os.path.basename(message.path)
|
||||
await self.body.update(syntax)
|
||||
self.body.home()
|
||||
|
||||
|
||||
MyApp.run(title="Code Viewer", log="textual.log")
|
||||
14
poetry.lock
generated
14
poetry.lock
generated
@@ -246,7 +246,7 @@ python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
description = "Project documentation with Markdown."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -281,7 +281,7 @@ mkdocs = ">=1.1,<2.0"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "7.1.11"
|
||||
version = "7.2.0"
|
||||
description = "A Material Design theme for MkDocs"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -563,7 +563,7 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
|
||||
type = "git"
|
||||
url = "git@github.com:willmcgugan/rich"
|
||||
reference = "link-id"
|
||||
resolved_reference = "f70eb3cac24f47443cdc571a05b255519deea9b4"
|
||||
resolved_reference = "8ae1d4ad0a36a84485acccd1768f1f2a122f2277"
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
@@ -815,16 +815,16 @@ mergedeep = [
|
||||
{file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"},
|
||||
]
|
||||
mkdocs = [
|
||||
{file = "mkdocs-1.2.1-py3-none-any.whl", hash = "sha256:11141126e5896dd9d279b3e4814eb488e409a0990fb638856255020406a8e2e7"},
|
||||
{file = "mkdocs-1.2.1.tar.gz", hash = "sha256:6e0ea175366e3a50d334597b0bc042b8cebd512398cdd3f6f34842d0ef524905"},
|
||||
{file = "mkdocs-1.2.2-py3-none-any.whl", hash = "sha256:d019ff8e17ec746afeb54eb9eb4112b5e959597aebc971da46a5c9486137f0ff"},
|
||||
{file = "mkdocs-1.2.2.tar.gz", hash = "sha256:a334f5bd98ec960638511366eb8c5abc9c99b9083a0ed2401d8791b112d6b078"},
|
||||
]
|
||||
mkdocs-autorefs = [
|
||||
{file = "mkdocs-autorefs-0.2.1.tar.gz", hash = "sha256:b8156d653ed91356e71675ce1fa1186d2b2c2085050012522895c9aa98fca3e5"},
|
||||
{file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"},
|
||||
]
|
||||
mkdocs-material = [
|
||||
{file = "mkdocs-material-7.1.11.tar.gz", hash = "sha256:cad3a693f1c28823370578e5b9c9aea418bddae0c7348ab734537391e9f2b1e5"},
|
||||
{file = "mkdocs_material-7.1.11-py2.py3-none-any.whl", hash = "sha256:0bcfb788020b72b0ebf5b2722ddf89534acaed8c3feb39c2d6dda239b49dec45"},
|
||||
{file = "mkdocs-material-7.2.0.tar.gz", hash = "sha256:9f43c5874e119b312a6f369ef363815c11f182b5cdeff4a3426615ebc4664ace"},
|
||||
{file = "mkdocs_material-7.2.0-py2.py3-none-any.whl", hash = "sha256:8b3750857e168a9ca20be34890791817090b016248a39be45069fab5343f1dc0"},
|
||||
]
|
||||
mkdocs-material-extensions = [
|
||||
{file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"},
|
||||
|
||||
@@ -9,7 +9,7 @@ class NoHandler(Exception):
|
||||
|
||||
class HandlerArguments(NamedTuple):
|
||||
modifiers: set[str]
|
||||
action: str
|
||||
action: Any
|
||||
|
||||
|
||||
def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArguments:
|
||||
|
||||
@@ -18,8 +18,10 @@ def parse(action: str) -> tuple[str, tuple[Any, ...]]:
|
||||
action_name, action_params_str = params_match.groups()
|
||||
try:
|
||||
action_params = ast.literal_eval(action_params_str)
|
||||
except Exception as error:
|
||||
raise ActionError(str(error))
|
||||
except Exception:
|
||||
raise ActionError(
|
||||
f"unable to parse {action_params_str!r} in action {action!r}"
|
||||
)
|
||||
else:
|
||||
action_name = action
|
||||
action_params = ()
|
||||
@@ -32,6 +34,8 @@ def parse(action: str) -> tuple[str, tuple[Any, ...]]:
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
print(parse("foo"))
|
||||
|
||||
print(parse("view.toggle('side')"))
|
||||
|
||||
print(parse("view.toggle"))
|
||||
|
||||
@@ -392,6 +392,7 @@ class App(MessagePump):
|
||||
Args:
|
||||
action (str): Action encoded in a string.
|
||||
"""
|
||||
self.log(action, default_namespace)
|
||||
target, params = actions.parse(action)
|
||||
if "." in target:
|
||||
destination, action_name = target.split(".", 1)
|
||||
@@ -400,7 +401,7 @@ class App(MessagePump):
|
||||
action_target = getattr(self, destination)
|
||||
else:
|
||||
action_target = default_namespace or self
|
||||
action_name = action
|
||||
action_name = target
|
||||
|
||||
log("ACTION", action_target, action_name)
|
||||
await self.dispatch_action(action_target, action_name, params)
|
||||
@@ -425,9 +426,14 @@ class App(MessagePump):
|
||||
modifiers, action = extract_handler_actions(event_name, style.meta)
|
||||
except NoHandler:
|
||||
return False
|
||||
await self.action(
|
||||
action, default_namespace=default_namespace, modifiers=modifiers
|
||||
)
|
||||
if isinstance(action, str):
|
||||
await self.action(
|
||||
action, default_namespace=default_namespace, modifiers=modifiers
|
||||
)
|
||||
elif isinstance(action, Callable):
|
||||
await action()
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
|
||||
|
||||
@@ -101,6 +101,7 @@ class Layout(ABC):
|
||||
|
||||
def reflow(self, width: int, height: int) -> ReflowResult:
|
||||
self.reset()
|
||||
log(" REFLOW", self)
|
||||
|
||||
map = self.generate_map(width, height)
|
||||
self._require_update = False
|
||||
|
||||
@@ -87,36 +87,26 @@ class GridLayout(Layout):
|
||||
return column_name not in self.hidden_columns
|
||||
|
||||
def show_row(self, row_name: str, visible: bool = True) -> bool:
|
||||
changed = False
|
||||
changed = (row_name in self.hidden_rows) == visible
|
||||
if visible:
|
||||
if not self.is_row_visible(row_name):
|
||||
self.require_update()
|
||||
changed = True
|
||||
self.hidden_rows.discard(row_name)
|
||||
self.require_update()
|
||||
else:
|
||||
if self.is_row_visible(row_name):
|
||||
self.require_update()
|
||||
changed = True
|
||||
self.hidden_rows.add(row_name)
|
||||
if changed:
|
||||
self.require_update()
|
||||
return changed
|
||||
return True
|
||||
return False
|
||||
|
||||
def show_column(self, column_name: str, visible: bool = True) -> bool:
|
||||
changed = False
|
||||
changed = (column_name in self.hidden_columns) == visible
|
||||
if visible:
|
||||
if not self.is_column_visible(column_name):
|
||||
self.require_update()
|
||||
changed = True
|
||||
self.hidden_rows.discard(column_name)
|
||||
self.require_update()
|
||||
self.hidden_columns.discard(column_name)
|
||||
else:
|
||||
if self.is_column_visible(column_name):
|
||||
self.require_update()
|
||||
changed = True
|
||||
self.hidden_rows.add(column_name)
|
||||
self.hidden_columns.add(column_name)
|
||||
if changed:
|
||||
self.require_update()
|
||||
return changed
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_column(
|
||||
self,
|
||||
|
||||
@@ -27,7 +27,7 @@ class PageRender:
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
style: StyleType = "",
|
||||
padding: PaddingDimensions = 1,
|
||||
padding: PaddingDimensions = (0, 0),
|
||||
) -> None:
|
||||
self.page = page
|
||||
self.renderable = renderable
|
||||
|
||||
@@ -5,5 +5,5 @@ from ..layouts.grid import GridLayout
|
||||
class GridView(View, layout=GridLayout):
|
||||
@property
|
||||
def grid(self) -> GridLayout:
|
||||
assert isinstance(self.layout, GridLayout), repr(self.layout_factory)
|
||||
assert isinstance(self.layout, GridLayout)
|
||||
return self.layout
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from logging import getLogger
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
ClassVar,
|
||||
@@ -85,7 +85,7 @@ class Widget(MessagePump):
|
||||
def __rich__(self) -> RenderableType:
|
||||
return self.render()
|
||||
|
||||
def watch(self, attribute_name, callback: Callable[[Any], None]) -> None:
|
||||
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
|
||||
watch(self, attribute_name, callback)
|
||||
|
||||
@property
|
||||
|
||||
@@ -4,13 +4,21 @@ from ._button import Button, ButtonPressed
|
||||
from ._placeholder import Placeholder
|
||||
from ._scroll_view import ScrollView
|
||||
from ._static import Static
|
||||
from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID
|
||||
from ._directory_tree import DirectoryTree, FileClick
|
||||
|
||||
__all__ = [
|
||||
"Footer",
|
||||
"Header",
|
||||
"Button",
|
||||
"ButtonPressed",
|
||||
"DirectoryTree",
|
||||
"FileClick",
|
||||
"Footer",
|
||||
"Header",
|
||||
"Placeholder",
|
||||
"ScrollView",
|
||||
"Static",
|
||||
"TreeClick",
|
||||
"TreeControl",
|
||||
"TreeNode",
|
||||
"NodeID",
|
||||
]
|
||||
|
||||
99
src/textual/widgets/_directory_tree.py
Normal file
99
src/textual/widgets/_directory_tree.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from os import scandir
|
||||
import os.path
|
||||
|
||||
from rich.console import RenderableType
|
||||
import rich.repr
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from .. import events
|
||||
from ..message import Message
|
||||
from .._types import MessageTarget
|
||||
from . import TreeControl, TreeClick, TreeNode, NodeID
|
||||
|
||||
|
||||
@dataclass
|
||||
class DirEntry:
|
||||
path: str
|
||||
is_dir: bool
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class FileClick(Message, bubble=True):
|
||||
def __init__(self, sender: MessageTarget, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__(sender)
|
||||
|
||||
|
||||
class DirectoryTree(TreeControl[DirEntry]):
|
||||
def __init__(self, path: str, name: str = None) -> None:
|
||||
self.path = path.rstrip("/")
|
||||
label = os.path.basename(self.path)
|
||||
data = DirEntry(path, True)
|
||||
super().__init__(label, name=name, data=data)
|
||||
self.root.tree.guide_style = "black"
|
||||
|
||||
async def watch_hover_node(self, hover_node: NodeID) -> None:
|
||||
for node in self.nodes.values():
|
||||
node.tree.guide_style = (
|
||||
"bold not dim red" if node.id == hover_node else "black"
|
||||
)
|
||||
|
||||
def render_node(self, node: TreeNode[DirEntry]) -> RenderableType:
|
||||
meta = {"@click": f"click_label({node.id})", "tree_node": node.id}
|
||||
label = Text(node.label) if isinstance(node.label, str) else node.label
|
||||
if node.id == self.hover_node:
|
||||
label.stylize("underline")
|
||||
if node.data.is_dir:
|
||||
label.stylize("bold magenta")
|
||||
icon = "📂" if node.expanded else "📁"
|
||||
else:
|
||||
label.stylize("bright_green")
|
||||
icon = "📄"
|
||||
label.highlight_regex(r"\..*$", "green")
|
||||
|
||||
if label.plain.startswith("."):
|
||||
label.stylize("dim")
|
||||
|
||||
icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label
|
||||
icon_label.apply_meta(meta)
|
||||
return icon_label
|
||||
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
await 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:
|
||||
await node.add(entry.name, DirEntry(entry.path, entry.is_dir()))
|
||||
node.loaded = True
|
||||
await node.expand()
|
||||
self.require_repaint()
|
||||
|
||||
async def message_tree_click(self, message: TreeClick[DirEntry]) -> None:
|
||||
dir_entry = message.node.data
|
||||
if not dir_entry.is_dir:
|
||||
await self.emit(FileClick(self, dir_entry.path))
|
||||
else:
|
||||
if not message.node.loaded:
|
||||
await self.load_directory(message.node)
|
||||
await message.node.expand()
|
||||
else:
|
||||
await message.node.toggle()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
|
||||
class TreeApp(App):
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
await self.view.dock(DirectoryTree("/Users/willmcgugan/projects"))
|
||||
|
||||
TreeApp.run(log="textual.log")
|
||||
@@ -20,7 +20,7 @@ class Header(Widget):
|
||||
self,
|
||||
*,
|
||||
tall: bool = True,
|
||||
style: StyleType = "white on blue",
|
||||
style: StyleType = "white on dark_green",
|
||||
clock: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
@@ -78,6 +78,9 @@ class ScrollView(View):
|
||||
)
|
||||
await self.layout.mount_all(self)
|
||||
|
||||
def home(self) -> None:
|
||||
self.x = self.y = 0
|
||||
|
||||
def scroll_up(self) -> None:
|
||||
self.target_y += 1.5
|
||||
self.animate("y", self.target_y, easing="out_cubic", speed=80)
|
||||
@@ -168,20 +171,14 @@ class ScrollView(View):
|
||||
self.y = self.validate_y(self.y)
|
||||
self.vscroll.virtual_size = self.page.virtual_size.height
|
||||
self.vscroll.window_size = self.size.height
|
||||
update = False
|
||||
|
||||
assert isinstance(self.layout, GridLayout)
|
||||
|
||||
if self.layout.show_column(
|
||||
"vscroll", self.page.virtual_size.height > self.size.height
|
||||
):
|
||||
update = True
|
||||
|
||||
self.hscroll.virtual_size = self.page.virtual_size.width
|
||||
self.hscroll.window_size = self.size.width
|
||||
|
||||
self.require_layout()
|
||||
if self.layout.show_row(
|
||||
"hscroll", self.page.virtual_size.width > self.size.width
|
||||
):
|
||||
update = True
|
||||
|
||||
if update:
|
||||
self.page.update()
|
||||
self.layout.reset_update()
|
||||
self.require_layout()
|
||||
|
||||
166
src/textual/widgets/_tree_control.py
Normal file
166
src/textual/widgets/_tree_control.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Generic, NewType, TypeVar
|
||||
|
||||
from rich.console import Console, ConsoleOptions, RenderableType
|
||||
|
||||
from rich.style import Style, StyleType
|
||||
from rich.styled import Styled
|
||||
from rich.text import Text, TextType
|
||||
from rich.tree import Tree
|
||||
from rich.padding import Padding, PaddingDimensions
|
||||
|
||||
from ..reactive import Reactive
|
||||
from .._types import MessageTarget
|
||||
from ..widget import Widget
|
||||
from ..message import Message
|
||||
|
||||
|
||||
NodeID = NewType("NodeID", int)
|
||||
|
||||
|
||||
NodeDataType = TypeVar("NodeDataType")
|
||||
|
||||
|
||||
class TreeNode(Generic[NodeDataType]):
|
||||
def __init__(
|
||||
self,
|
||||
node_id: NodeID,
|
||||
control: TreeControl,
|
||||
tree: Tree,
|
||||
label: TextType,
|
||||
data: NodeDataType,
|
||||
) -> None:
|
||||
self._node_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
|
||||
|
||||
@property
|
||||
def id(self) -> NodeID:
|
||||
return self._node_id
|
||||
|
||||
@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 tree(self) -> Tree:
|
||||
return self._tree
|
||||
|
||||
async def expand(self, expanded: bool = True) -> None:
|
||||
self._expanded = expanded
|
||||
self._tree.expanded = expanded
|
||||
self._control.require_repaint()
|
||||
|
||||
async def toggle(self) -> None:
|
||||
await self.expand(not self._expanded)
|
||||
|
||||
async def add(self, label: TextType, data: NodeDataType) -> None:
|
||||
await self._control.add(self._node_id, label, data=data)
|
||||
self._empty = False
|
||||
|
||||
def __rich__(self) -> RenderableType:
|
||||
return self._control.render_node(self)
|
||||
|
||||
|
||||
class TreeClick(Generic[NodeDataType], Message, bubble=True):
|
||||
def __init__(self, sender: MessageTarget, node: TreeNode[NodeDataType]) -> None:
|
||||
self.node = node
|
||||
super().__init__(sender)
|
||||
|
||||
|
||||
class TreeControl(Generic[NodeDataType], Widget):
|
||||
def __init__(
|
||||
self,
|
||||
label: TextType,
|
||||
data: NodeDataType,
|
||||
*,
|
||||
name: str | None = None,
|
||||
padding: PaddingDimensions = (1, 1),
|
||||
) -> None:
|
||||
self.data = data
|
||||
|
||||
self._node_id = NodeID(0)
|
||||
self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {}
|
||||
self._tree = Tree(label)
|
||||
self.root: TreeNode[NodeDataType] = TreeNode(
|
||||
self._node_id, self, self._tree, label, data
|
||||
)
|
||||
self._tree.label = self.root
|
||||
self.nodes[NodeID(self._node_id)] = self.root
|
||||
self.padding = padding
|
||||
super().__init__(name=name)
|
||||
|
||||
hover_node: Reactive[NodeID | None] = Reactive(None)
|
||||
|
||||
async 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_node: TreeNode[NodeDataType] = TreeNode(
|
||||
self._node_id, self, child_tree, label, data
|
||||
)
|
||||
child_tree.label = child_node
|
||||
self.nodes[self._node_id] = child_node
|
||||
|
||||
self.require_repaint()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return Padding(self._tree, self.padding)
|
||||
|
||||
def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType:
|
||||
meta = {"@click": f"click_label({node.id})", "tree_node": node.id}
|
||||
label = Text(node.label) if isinstance(node.label, str) else node.label
|
||||
if node.id == self.hover_node:
|
||||
label.stylize("underline")
|
||||
label.apply_meta(meta)
|
||||
label.no_wrap = True
|
||||
label.overflow = "ellipsis"
|
||||
return label
|
||||
|
||||
async def action_click_label(self, node_id: NodeID) -> None:
|
||||
node = self.nodes[node_id]
|
||||
await self.post_message(TreeClick(self, node))
|
||||
|
||||
async def on_mouse_move(self, event: events.MouseMove) -> None:
|
||||
self.hover_node = event.style.meta.get("tree_node")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
|
||||
class TreeApp(App):
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
await self.view.dock(TreeControl("Tree Root", data="foo"))
|
||||
|
||||
async def message_tree_click(self, message: TreeClick) -> None:
|
||||
if message.node.empty:
|
||||
await message.node.add("foo")
|
||||
await message.node.add("bar")
|
||||
await message.node.add("baz")
|
||||
await message.node.expand()
|
||||
else:
|
||||
await message.node.toggle()
|
||||
|
||||
TreeApp.run(log="textual.log")
|
||||
Reference in New Issue
Block a user