From 36b262120232c3ed2fbb839f7009ed13ee76fb31 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Jul 2021 20:04:49 +0100 Subject: [PATCH] eol --- examples/code_viewer.py | 48 +++++++ poetry.lock | 14 +-- src/textual/_event_broker.py | 2 +- src/textual/actions.py | 8 +- src/textual/app.py | 14 ++- src/textual/layout.py | 1 + src/textual/layouts/grid.py | 30 ++--- src/textual/page.py | 2 +- src/textual/views/_grid_view.py | 2 +- src/textual/widget.py | 4 +- src/textual/widgets/__init__.py | 12 +- src/textual/widgets/_directory_tree.py | 99 +++++++++++++++ src/textual/widgets/_header.py | 2 +- src/textual/widgets/_scroll_view.py | 19 ++- src/textual/widgets/_tree_control.py | 166 +++++++++++++++++++++++++ 15 files changed, 371 insertions(+), 52 deletions(-) create mode 100644 examples/code_viewer.py create mode 100644 src/textual/widgets/_directory_tree.py create mode 100644 src/textual/widgets/_tree_control.py diff --git a/examples/code_viewer.py b/examples/code_viewer.py new file mode 100644 index 000000000..6fbdbafbf --- /dev/null +++ b/examples/code_viewer.py @@ -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") diff --git a/poetry.lock b/poetry.lock index 2e6fd2606..321022a5c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, diff --git a/src/textual/_event_broker.py b/src/textual/_event_broker.py index 865b98de2..a5bd074ac 100644 --- a/src/textual/_event_broker.py +++ b/src/textual/_event_broker.py @@ -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: diff --git a/src/textual/actions.py b/src/textual/actions.py index b8073859f..41839834b 100644 --- a/src/textual/actions.py +++ b/src/textual/actions.py @@ -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")) diff --git a/src/textual/app.py b/src/textual/app.py index 235825b23..cdb89c8ad 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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: diff --git a/src/textual/layout.py b/src/textual/layout.py index ee17442d6..ea3def14e 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -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 diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 36f2f5f74..afeb7ecfd 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -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, diff --git a/src/textual/page.py b/src/textual/page.py index decb90b8e..2e759654e 100644 --- a/src/textual/page.py +++ b/src/textual/page.py @@ -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 diff --git a/src/textual/views/_grid_view.py b/src/textual/views/_grid_view.py index e90ebda9b..af04b6d3f 100644 --- a/src/textual/views/_grid_view.py +++ b/src/textual/views/_grid_view.py @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index f0b3c92a7..edea59421 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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 diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 1640bf07a..3488b26e3 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -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", ] diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py new file mode 100644 index 000000000..d21f05aa7 --- /dev/null +++ b/src/textual/widgets/_directory_tree.py @@ -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") diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 7aa1ff865..dd52e3d23 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -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__() diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index ac3c1e403..0e2b4f185 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -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() diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py new file mode 100644 index 000000000..7212c3b09 --- /dev/null +++ b/src/textual/widgets/_tree_control.py @@ -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")