This commit is contained in:
Will McGugan
2021-07-22 20:04:49 +01:00
parent 42841c5b2c
commit 36b2621202
15 changed files with 371 additions and 52 deletions

48
examples/code_viewer.py Normal file
View 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
View File

@@ -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"},

View File

@@ -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:

View File

@@ -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"))

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",
]

View 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")

View File

@@ -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__()

View File

@@ -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()

View 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")