add keys to tree control

This commit is contained in:
Will McGugan
2021-08-21 11:19:06 +01:00
parent f1107a19a9
commit 6ec37ce82f
13 changed files with 248 additions and 45 deletions

View File

@@ -41,7 +41,7 @@ class MyApp(App):
# Note the directory is also in a scroll view # Note the directory is also in a scroll view
await self.view.dock( await self.view.dock(
ScrollView(self.directory), edge="left", size=32, name="sidebar" ScrollView(self.directory), edge="left", size=64, name="sidebar"
) )
await self.view.dock(self.body, edge="top") await self.view.dock(self.body, edge="top")

View File

@@ -1,10 +1,11 @@
from typing import Dict, Tuple, Union from typing import Dict, Tuple
from .keys import Keys from .keys import Keys
# Mapping of vt100 escape codes to Keys. # Mapping of vt100 escape codes to Keys.
ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = {
# Control keys. # Control keys.
"\r": (Keys.Enter,),
"\x00": (Keys.ControlAt,), # Control-At (Also for Ctrl-Space) "\x00": (Keys.ControlAt,), # Control-At (Also for Ctrl-Space)
"\x01": (Keys.ControlA,), # Control-A (home) "\x01": (Keys.ControlA,), # Control-A (home)
"\x02": (Keys.ControlB,), # Control-B (emacs cursor left) "\x02": (Keys.ControlB,), # Control-B (emacs cursor left)
@@ -18,7 +19,7 @@ ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = {
"\x0a": (Keys.ControlJ,), # Control-J (10) (Identical to '\n') "\x0a": (Keys.ControlJ,), # Control-J (10) (Identical to '\n')
"\x0b": (Keys.ControlK,), # Control-K (delete until end of line; vertical tab) "\x0b": (Keys.ControlK,), # Control-K (delete until end of line; vertical tab)
"\x0c": (Keys.ControlL,), # Control-L (clear; form feed) "\x0c": (Keys.ControlL,), # Control-L (clear; form feed)
"\x0d": (Keys.ControlM,), # Control-M (13) (Identical to '\r') # "\x0d": (Keys.ControlM,), # Control-M (13) (Identical to '\r')
"\x0e": (Keys.ControlN,), # Control-N (14) (history forward) "\x0e": (Keys.ControlN,), # Control-N (14) (history forward)
"\x0f": (Keys.ControlO,), # Control-O (15) "\x0f": (Keys.ControlO,), # Control-O (15)
"\x10": (Keys.ControlP,), # Control-P (16) (history back) "\x10": (Keys.ControlP,), # Control-P (16) (history back)

View File

@@ -91,7 +91,6 @@ class XTermParser(Parser[events.Event]):
on_token(event) on_token(event)
break break
else: else:
keys = get_ansi_sequence(character, None) keys = get_ansi_sequence(character, None)
if keys is not None: if keys is not None:
for key in keys: for key in keys:

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from asyncio import Event
from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar
import rich.repr import rich.repr

View File

@@ -131,10 +131,10 @@ class Size(NamedTuple):
class Region(NamedTuple): class Region(NamedTuple):
"""Defines a rectangular region.""" """Defines a rectangular region."""
x: int x: int = 0
y: int y: int = 0
width: int width: int = 0
height: int height: int = 0
@classmethod @classmethod
def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region: def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region:

View File

@@ -16,6 +16,7 @@ class Keys(str, Enum):
Escape = "escape" # Also Control-[ Escape = "escape" # Also Control-[
ShiftEscape = "shift+escape" ShiftEscape = "shift+escape"
Return = "return"
ControlAt = "ctrl+@" # Also Control-Space. ControlAt = "ctrl+@" # Also Control-Space.
@@ -186,10 +187,10 @@ class Keys(str, Enum):
Ignore = "<ignore>" Ignore = "<ignore>"
# Some 'Key' aliases (for backwardshift+compatibility). # Some 'Key' aliases (for backwardshift+compatibility).
ControlSpace = ControlAt ControlSpace = "ctrl-at"
Tab = ControlI Tab = "tab"
Enter = ControlM Enter = "enter"
Backspace = ControlH Backspace = "backspace"
# ShiftControl was renamed to ControlShift in # ShiftControl was renamed to ControlShift in
# 888fcb6fa4efea0de8333177e1bbc792f3ff3c24 (20 Feb 2020). # 888fcb6fa4efea0de8333177e1bbc792f3ff3c24 (20 Feb 2020).

View File

@@ -30,3 +30,10 @@ class UpdateMessage(Message, verbosity=3):
class LayoutMessage(Message, verbosity=3): class LayoutMessage(Message, verbosity=3):
def can_replace(self, message: Message) -> bool: def can_replace(self, message: Message) -> bool:
return isinstance(message, LayoutMessage) return isinstance(message, LayoutMessage)
@rich.repr.auto
class CursorMoveMessage(Message, bubble=True):
def __init__(self, sender: MessagePump, line: int) -> None:
self.line = line
super().__init__(sender)

View File

@@ -70,6 +70,7 @@ class Reactive(Generic[ReactiveType]):
value = validate_function(value) value = validate_function(value)
if current_value != value or self._first: if current_value != value or self._first:
self._first = False self._first = False
setattr(obj, self.internal_name, value) setattr(obj, self.internal_name, value)
self.check_watchers(obj, name, current_value) self.check_watchers(obj, name, current_value)

View File

@@ -24,6 +24,7 @@ from rich.text import TextType
from . import events from . import events
from ._animator import BoundAnimator from ._animator import BoundAnimator
from ._callback import invoke
from ._context import active_app from ._context import active_app
from .geometry import Size from .geometry import Size
from .message import Message from .message import Message
@@ -43,6 +44,14 @@ class RenderCache(NamedTuple):
size: Size size: Size
lines: Lines lines: Lines
@property
def cursor_line(self) -> int | None:
for index, line in enumerate(self.lines):
for text, style, control in line:
if style and style._meta and style.meta.get("cursor", False):
return index
return None
@rich.repr.auto @rich.repr.auto
class Widget(MessagePump): class Widget(MessagePump):
@@ -164,23 +173,18 @@ class Widget(MessagePump):
def _update_size(self, size: Size) -> None: def _update_size(self, size: Size) -> None:
self._size = size self._size = size
def render_lines(self) -> RenderCache: def render_lines(self) -> None:
width, height = self.size width, height = self.size
renderable = self.render_styled() renderable = self.render_styled()
options = self.console.options.update_dimensions(width, height) options = self.console.options.update_dimensions(width, height)
lines = self.console.render_lines(renderable, options) lines = self.console.render_lines(renderable, options)
self.render_cache = RenderCache(self.size, lines) self.render_cache = RenderCache(self.size, lines)
return self.render_cache
def render_lines_free(self, width: int) -> RenderCache:
def render_lines_free(self, width: int) -> None:
renderable = self.render_styled() renderable = self.render_styled()
options = self.console.options.update(width=width, height=None) options = self.console.options.update(width=width, height=None)
lines = self.console.render_lines(renderable, options) lines = self.console.render_lines(renderable, options)
self.render_cache = RenderCache(Size(width, len(lines)), lines) self.render_cache = RenderCache(Size(width, len(lines)), lines)
return self.render_cache
def _get_lines(self) -> Lines: def _get_lines(self) -> Lines:
"""Get render lines for given dimensions. """Get render lines for given dimensions.
@@ -193,7 +197,7 @@ class Widget(MessagePump):
Lines: [description] Lines: [description]
""" """
if self.render_cache is None: if self.render_cache is None:
self.render_cache = self.render_lines() self.render_lines()
lines = self.render_cache.lines lines = self.render_cache.lines
return lines return lines
@@ -297,7 +301,7 @@ class Widget(MessagePump):
key_method = getattr(self, f"key_{event.key}", None) key_method = getattr(self, f"key_{event.key}", None)
if key_method is not None: if key_method is not None:
await key_method() await invoke(key_method, event)
async def on_mouse_down(self, event: events.MouseUp) -> None: async def on_mouse_down(self, event: events.MouseUp) -> None:
await self.broker_event("mouse.down", event) await self.broker_event("mouse.down", event)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from os import scandir from os import scandir
import os.path import os.path
@@ -11,6 +12,7 @@ from rich.tree import Tree
from .. import events from .. import events
from ..message import Message from ..message import Message
from ..reactive import Reactive
from .._types import MessageTarget from .._types import MessageTarget
from . import TreeControl, TreeClick, TreeNode, NodeID from . import TreeControl, TreeClick, TreeNode, NodeID
@@ -36,6 +38,14 @@ class DirectoryTree(TreeControl[DirEntry]):
super().__init__(label, name=name, data=data) super().__init__(label, name=name, data=data)
self.root.tree.guide_style = "black" self.root.tree.guide_style = "black"
has_focus: Reactive[bool] = Reactive(False)
def on_focus(self) -> None:
self.has_focus = True
def on_blur(self) -> None:
self.has_focus = False
async def watch_hover_node(self, hover_node: NodeID) -> None: async def watch_hover_node(self, hover_node: NodeID) -> None:
for node in self.nodes.values(): for node in self.nodes.values():
node.tree.guide_style = ( node.tree.guide_style = (
@@ -44,13 +54,62 @@ class DirectoryTree(TreeControl[DirEntry]):
self.refresh(layout=True) self.refresh(layout=True)
def render_node(self, node: TreeNode[DirEntry]) -> RenderableType: def render_node(self, node: TreeNode[DirEntry]) -> RenderableType:
meta = {"@click": f"click_label({node.id})", "tree_node": node.id} # TODO: Optimize / cache this
return self.render_tree_label(
node,
node.data.is_dir,
node.expanded,
node.is_cursor,
node.id == self.hover_node,
self.has_focus,
)
# meta = {
# "@click": f"click_label({node.id})",
# "tree_node": node.id,
# "cursor": node.is_cursor,
# }
# 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")
# if node.is_cursor and self.has_focus:
# label.stylize("reverse")
# icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label
# icon_label.apply_meta(meta)
# return icon_label
@lru_cache(maxsize=1024 * 32)
def render_tree_label(
self,
node: TreeNode[DirEntry],
is_dir: bool,
expanded: bool,
is_cursor: bool,
is_hover: bool,
has_focus: bool,
) -> RenderableType:
meta = {
"@click": f"click_label({node.id})",
"tree_node": node.id,
"cursor": node.is_cursor,
}
label = Text(node.label) if isinstance(node.label, str) else node.label label = Text(node.label) if isinstance(node.label, str) else node.label
if node.id == self.hover_node: if is_hover:
label.stylize("underline") label.stylize("underline")
if node.data.is_dir: if is_dir:
label.stylize("bold magenta") label.stylize("bold magenta")
icon = "📂" if node.expanded else "📁" icon = "📂" if expanded else "📁"
else: else:
label.stylize("bright_green") label.stylize("bright_green")
icon = "📄" icon = "📄"
@@ -59,6 +118,9 @@ class DirectoryTree(TreeControl[DirEntry]):
if label.plain.startswith("."): if label.plain.startswith("."):
label.stylize("dim") label.stylize("dim")
if is_cursor and has_focus:
label.stylize("reverse")
icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label
icon_label.apply_meta(meta) icon_label.apply_meta(meta)
return icon_label return icon_label

View File

@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from logging import PlaceHolder
from rich.console import RenderableType from rich.console import RenderableType
from rich.style import StyleType from rich.style import StyleType
@@ -8,11 +7,9 @@ from rich.style import StyleType
from .. import events from .. import events
from ..layouts.grid import GridLayout from ..layouts.grid import GridLayout
from ..message import Message from ..message import Message
from ..messages import UpdateMessage from ..messages import CursorMoveMessage
from ..scrollbar import ScrollTo, ScrollBar from ..scrollbar import ScrollTo, ScrollBar
from ..geometry import clamp, Offset, Size from ..geometry import clamp
from ..page import Page
from ..reactive import watch
from ..view import View from ..view import View
from ..widget import Widget from ..widget import Widget
@@ -121,6 +118,12 @@ class ScrollView(View):
self.target_x += self.size.width self.target_x += self.size.width
self.animate("x", self.target_x, speed=120, easing="out_cubic") self.animate("x", self.target_x, speed=120, easing="out_cubic")
def scroll_in_to_view(self, line: int) -> None:
if line < self.y:
self.target_y = line
elif line > self.y + self.size.height:
self.target_y = line - self.size.height
async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
self.scroll_up() self.scroll_up()
@@ -191,3 +194,6 @@ class ScrollView(View):
self.refresh() self.refresh()
if self.layout.show_row("hscroll", virtual_size.width > self.size.width): if self.layout.show_row("hscroll", virtual_size.width > self.size.width):
self.refresh() self.refresh()
async def message_cursor_move(self, message: CursorMoveMessage) -> None:
self.scroll_in_to_view(message.line)

View File

@@ -1,20 +1,21 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Generic, NewType, TypeVar from functools import lru_cache
from rich.console import Console, ConsoleOptions, RenderableType from typing import Generic, NewType, TypeVar
from rich.style import Style, StyleType from rich.console import RenderableType
from rich.styled import Styled
from rich.text import Text, TextType from rich.text import Text, TextType
from rich.tree import Tree from rich.tree import Tree
from rich.padding import Padding, PaddingDimensions from rich.padding import PaddingDimensions
from .. import log from .. import log
from .. import events
from ..reactive import Reactive from ..reactive import Reactive
from .._types import MessageTarget from .._types import MessageTarget
from ..widget import Widget from ..widget import Widget
from ..message import Message from ..message import Message
from ..messages import CursorMoveMessage
NodeID = NewType("NodeID", int) NodeID = NewType("NodeID", int)
@@ -26,12 +27,14 @@ NodeDataType = TypeVar("NodeDataType")
class TreeNode(Generic[NodeDataType]): class TreeNode(Generic[NodeDataType]):
def __init__( def __init__(
self, self,
parent: TreeNode[NodeDataType] | None,
node_id: NodeID, node_id: NodeID,
control: TreeControl, control: TreeControl,
tree: Tree, tree: Tree,
label: TextType, label: TextType,
data: NodeDataType, data: NodeDataType,
) -> None: ) -> None:
self.parent = parent
self._node_id = node_id self._node_id = node_id
self._control = control self._control = control
self._tree = tree self._tree = tree
@@ -41,6 +44,7 @@ class TreeNode(Generic[NodeDataType]):
self._expanded = False self._expanded = False
self._empty = False self._empty = False
self._tree.expanded = False self._tree.expanded = False
self.children: list[TreeNode] = []
@property @property
def id(self) -> NodeID: def id(self) -> NodeID:
@@ -58,14 +62,88 @@ class TreeNode(Generic[NodeDataType]):
def expanded(self) -> bool: def expanded(self) -> bool:
return self._expanded return self._expanded
@property
def is_cursor(self) -> bool:
return self.control.cursor == self.id
@property @property
def tree(self) -> Tree: def tree(self) -> Tree:
return 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:
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
async def expand(self, expanded: bool = True) -> None: async def expand(self, expanded: bool = True) -> None:
self._expanded = expanded self._expanded = expanded
self._tree.expanded = expanded self._tree.expanded = expanded
self._control.refresh() self._control.refresh(layout=True)
async def toggle(self) -> None: async def toggle(self) -> None:
await self.expand(not self._expanded) await self.expand(not self._expanded)
@@ -100,14 +178,17 @@ class TreeControl(Generic[NodeDataType], Widget):
self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {} self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {}
self._tree = Tree(label) self._tree = Tree(label)
self.root: TreeNode[NodeDataType] = TreeNode( self.root: TreeNode[NodeDataType] = TreeNode(
self._node_id, self, self._tree, label, data None, self._node_id, self, self._tree, label, data
) )
self._tree.label = self.root self._tree.label = self.root
self.nodes[NodeID(self._node_id)] = self.root self.nodes[NodeID(self._node_id)] = self.root
super().__init__(name=name) super().__init__(name=name)
self.padding = padding self.padding = padding
self.cursor = self.root.id
hover_node: Reactive[NodeID | None] = Reactive(None) hover_node: Reactive[NodeID | None] = Reactive(None)
cursor: Reactive[NodeID] = Reactive(NodeID(0), layout=True)
async def add( async def add(
self, self,
@@ -119,33 +200,70 @@ class TreeControl(Generic[NodeDataType], Widget):
self._node_id = NodeID(self._node_id + 1) self._node_id = NodeID(self._node_id + 1)
child_tree = parent._tree.add(label) child_tree = parent._tree.add(label)
child_node: TreeNode[NodeDataType] = TreeNode( child_node: TreeNode[NodeDataType] = TreeNode(
self._node_id, self, child_tree, label, data parent, self._node_id, self, child_tree, label, data
) )
parent.children.append(child_node)
child_tree.label = child_node child_tree.label = child_node
self.nodes[self._node_id] = child_node self.nodes[self._node_id] = child_node
self.refresh() self.refresh(layout=True)
def render(self) -> RenderableType: def render(self) -> RenderableType:
return self._tree return self._tree
def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType: def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType:
meta = {"@click": f"click_label({node.id})", "tree_node": node.id} label = (
label = Text(node.label) if isinstance(node.label, str) else node.label Text(node.label, no_wrap=True, overflow="ellipsis")
if isinstance(node.label, str)
else node.label
)
if node.id == self.hover_node: if node.id == self.hover_node:
label.stylize("underline") label.stylize("underline")
label.apply_meta(meta) label.apply_meta(
label.no_wrap = True {
label.overflow = "ellipsis" "@click": f"click_label({node.id})",
"tree_node": node.id,
"cursor": node.is_cursor,
}
)
return label return label
async def action_click_label(self, node_id: NodeID) -> None: async def action_click_label(self, node_id: NodeID) -> None:
node = self.nodes[node_id] node = self.nodes[node_id]
self.cursor = node.id
await self.post_message(TreeClick(self, node)) await self.post_message(TreeClick(self, node))
async def on_mouse_move(self, event: events.MouseMove) -> None: async def on_mouse_move(self, event: events.MouseMove) -> None:
self.hover_node = event.style.meta.get("tree_node") self.hover_node = event.style.meta.get("tree_node")
async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event)
async def key_down(self, event: events.Key) -> None:
await self.cursor_down()
event.stop()
async def key_up(self, event: events.Key) -> None:
await self.cursor_up()
event.stop()
async def key_enter(self, event: events.Key) -> None:
cursor_node = self.nodes[self.cursor]
event.stop()
await self.post_message(TreeClick(self, cursor_node))
async def cursor_down(self) -> None:
cursor_node = self.nodes[self.cursor]
next_node = cursor_node.next_node
if next_node is not None:
self.hover_node = self.cursor = next_node.id
async def cursor_up(self) -> None:
cursor_node = self.nodes[self.cursor]
previous_node = cursor_node.previous_node
if previous_node is not None:
self.hover_node = self.cursor = previous_node.id
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -87,6 +87,11 @@ def test_point_blend():
assert Offset(1, 2).blend(Offset(3, 4), 0.5) == Offset(2, 3) assert Offset(1, 2).blend(Offset(3, 4), 0.5) == Offset(2, 3)
def test_region_null():
assert Region() == Region(0, 0, 0, 0)
assert not Region()
def test_region_from_origin(): def test_region_from_origin():
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)