mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
add keys to tree control
This commit is contained in:
@@ -41,7 +41,7 @@ class MyApp(App):
|
||||
|
||||
# Note the directory is also in a scroll view
|
||||
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")
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from typing import Dict, Tuple, Union
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from .keys import Keys
|
||||
|
||||
# Mapping of vt100 escape codes to Keys.
|
||||
ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = {
|
||||
# Control keys.
|
||||
"\r": (Keys.Enter,),
|
||||
"\x00": (Keys.ControlAt,), # Control-At (Also for Ctrl-Space)
|
||||
"\x01": (Keys.ControlA,), # Control-A (home)
|
||||
"\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')
|
||||
"\x0b": (Keys.ControlK,), # Control-K (delete until end of line; vertical tab)
|
||||
"\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)
|
||||
"\x0f": (Keys.ControlO,), # Control-O (15)
|
||||
"\x10": (Keys.ControlP,), # Control-P (16) (history back)
|
||||
|
||||
@@ -91,7 +91,6 @@ class XTermParser(Parser[events.Event]):
|
||||
on_token(event)
|
||||
break
|
||||
else:
|
||||
|
||||
keys = get_ansi_sequence(character, None)
|
||||
if keys is not None:
|
||||
for key in keys:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Event
|
||||
from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar
|
||||
|
||||
import rich.repr
|
||||
|
||||
@@ -131,10 +131,10 @@ class Size(NamedTuple):
|
||||
class Region(NamedTuple):
|
||||
"""Defines a rectangular region."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region:
|
||||
|
||||
@@ -16,6 +16,7 @@ class Keys(str, Enum):
|
||||
|
||||
Escape = "escape" # Also Control-[
|
||||
ShiftEscape = "shift+escape"
|
||||
Return = "return"
|
||||
|
||||
ControlAt = "ctrl+@" # Also Control-Space.
|
||||
|
||||
@@ -186,10 +187,10 @@ class Keys(str, Enum):
|
||||
Ignore = "<ignore>"
|
||||
|
||||
# Some 'Key' aliases (for backwardshift+compatibility).
|
||||
ControlSpace = ControlAt
|
||||
Tab = ControlI
|
||||
Enter = ControlM
|
||||
Backspace = ControlH
|
||||
ControlSpace = "ctrl-at"
|
||||
Tab = "tab"
|
||||
Enter = "enter"
|
||||
Backspace = "backspace"
|
||||
|
||||
# ShiftControl was renamed to ControlShift in
|
||||
# 888fcb6fa4efea0de8333177e1bbc792f3ff3c24 (20 Feb 2020).
|
||||
|
||||
@@ -30,3 +30,10 @@ class UpdateMessage(Message, verbosity=3):
|
||||
class LayoutMessage(Message, verbosity=3):
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
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)
|
||||
|
||||
@@ -70,6 +70,7 @@ class Reactive(Generic[ReactiveType]):
|
||||
value = validate_function(value)
|
||||
|
||||
if current_value != value or self._first:
|
||||
|
||||
self._first = False
|
||||
setattr(obj, self.internal_name, value)
|
||||
self.check_watchers(obj, name, current_value)
|
||||
|
||||
@@ -24,6 +24,7 @@ from rich.text import TextType
|
||||
|
||||
from . import events
|
||||
from ._animator import BoundAnimator
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
from .geometry import Size
|
||||
from .message import Message
|
||||
@@ -43,6 +44,14 @@ class RenderCache(NamedTuple):
|
||||
size: Size
|
||||
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
|
||||
class Widget(MessagePump):
|
||||
@@ -164,23 +173,18 @@ class Widget(MessagePump):
|
||||
def _update_size(self, size: Size) -> None:
|
||||
self._size = size
|
||||
|
||||
def render_lines(self) -> RenderCache:
|
||||
def render_lines(self) -> None:
|
||||
width, height = self.size
|
||||
renderable = self.render_styled()
|
||||
options = self.console.options.update_dimensions(width, height)
|
||||
lines = self.console.render_lines(renderable, options)
|
||||
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()
|
||||
|
||||
options = self.console.options.update(width=width, height=None)
|
||||
|
||||
lines = self.console.render_lines(renderable, options)
|
||||
self.render_cache = RenderCache(Size(width, len(lines)), lines)
|
||||
return self.render_cache
|
||||
|
||||
def _get_lines(self) -> Lines:
|
||||
"""Get render lines for given dimensions.
|
||||
@@ -193,7 +197,7 @@ class Widget(MessagePump):
|
||||
Lines: [description]
|
||||
"""
|
||||
if self.render_cache is None:
|
||||
self.render_cache = self.render_lines()
|
||||
self.render_lines()
|
||||
lines = self.render_cache.lines
|
||||
return lines
|
||||
|
||||
@@ -297,7 +301,7 @@ class Widget(MessagePump):
|
||||
|
||||
key_method = getattr(self, f"key_{event.key}", 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:
|
||||
await self.broker_event("mouse.down", event)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from os import scandir
|
||||
import os.path
|
||||
|
||||
@@ -11,6 +12,7 @@ from rich.tree import Tree
|
||||
|
||||
from .. import events
|
||||
from ..message import Message
|
||||
from ..reactive import Reactive
|
||||
from .._types import MessageTarget
|
||||
from . import TreeControl, TreeClick, TreeNode, NodeID
|
||||
|
||||
@@ -36,6 +38,14 @@ class DirectoryTree(TreeControl[DirEntry]):
|
||||
super().__init__(label, name=name, data=data)
|
||||
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:
|
||||
for node in self.nodes.values():
|
||||
node.tree.guide_style = (
|
||||
@@ -44,13 +54,62 @@ class DirectoryTree(TreeControl[DirEntry]):
|
||||
self.refresh(layout=True)
|
||||
|
||||
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
|
||||
if node.id == self.hover_node:
|
||||
if is_hover:
|
||||
label.stylize("underline")
|
||||
if node.data.is_dir:
|
||||
if is_dir:
|
||||
label.stylize("bold magenta")
|
||||
icon = "📂" if node.expanded else "📁"
|
||||
icon = "📂" if expanded else "📁"
|
||||
else:
|
||||
label.stylize("bright_green")
|
||||
icon = "📄"
|
||||
@@ -59,6 +118,9 @@ class DirectoryTree(TreeControl[DirEntry]):
|
||||
if label.plain.startswith("."):
|
||||
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.apply_meta(meta)
|
||||
return icon_label
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
from logging import PlaceHolder
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.style import StyleType
|
||||
@@ -8,11 +7,9 @@ from rich.style import StyleType
|
||||
from .. import events
|
||||
from ..layouts.grid import GridLayout
|
||||
from ..message import Message
|
||||
from ..messages import UpdateMessage
|
||||
from ..messages import CursorMoveMessage
|
||||
from ..scrollbar import ScrollTo, ScrollBar
|
||||
from ..geometry import clamp, Offset, Size
|
||||
from ..page import Page
|
||||
from ..reactive import watch
|
||||
from ..geometry import clamp
|
||||
from ..view import View
|
||||
from ..widget import Widget
|
||||
|
||||
@@ -121,6 +118,12 @@ class ScrollView(View):
|
||||
self.target_x += self.size.width
|
||||
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:
|
||||
self.scroll_up()
|
||||
|
||||
@@ -191,3 +194,6 @@ class ScrollView(View):
|
||||
self.refresh()
|
||||
if self.layout.show_row("hscroll", virtual_size.width > self.size.width):
|
||||
self.refresh()
|
||||
|
||||
async def message_cursor_move(self, message: CursorMoveMessage) -> None:
|
||||
self.scroll_in_to_view(message.line)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
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.styled import Styled
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text, TextType
|
||||
from rich.tree import Tree
|
||||
from rich.padding import Padding, PaddingDimensions
|
||||
from rich.padding import PaddingDimensions
|
||||
|
||||
from .. import log
|
||||
from .. import events
|
||||
from ..reactive import Reactive
|
||||
from .._types import MessageTarget
|
||||
from ..widget import Widget
|
||||
from ..message import Message
|
||||
from ..messages import CursorMoveMessage
|
||||
|
||||
|
||||
NodeID = NewType("NodeID", int)
|
||||
@@ -26,12 +27,14 @@ NodeDataType = TypeVar("NodeDataType")
|
||||
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._node_id = node_id
|
||||
self._control = control
|
||||
self._tree = tree
|
||||
@@ -41,6 +44,7 @@ class TreeNode(Generic[NodeDataType]):
|
||||
self._expanded = False
|
||||
self._empty = False
|
||||
self._tree.expanded = False
|
||||
self.children: list[TreeNode] = []
|
||||
|
||||
@property
|
||||
def id(self) -> NodeID:
|
||||
@@ -58,14 +62,88 @@ class TreeNode(Generic[NodeDataType]):
|
||||
def expanded(self) -> bool:
|
||||
return self._expanded
|
||||
|
||||
@property
|
||||
def is_cursor(self) -> bool:
|
||||
return self.control.cursor == self.id
|
||||
|
||||
@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:
|
||||
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:
|
||||
self._expanded = expanded
|
||||
self._tree.expanded = expanded
|
||||
self._control.refresh()
|
||||
self._control.refresh(layout=True)
|
||||
|
||||
async def toggle(self) -> None:
|
||||
await self.expand(not self._expanded)
|
||||
@@ -100,14 +178,17 @@ class TreeControl(Generic[NodeDataType], Widget):
|
||||
self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {}
|
||||
self._tree = Tree(label)
|
||||
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.nodes[NodeID(self._node_id)] = self.root
|
||||
super().__init__(name=name)
|
||||
self.padding = padding
|
||||
self.cursor = self.root.id
|
||||
|
||||
hover_node: Reactive[NodeID | None] = Reactive(None)
|
||||
cursor: Reactive[NodeID] = Reactive(NodeID(0), layout=True)
|
||||
|
||||
async def add(
|
||||
self,
|
||||
@@ -119,33 +200,70 @@ class TreeControl(Generic[NodeDataType], Widget):
|
||||
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
|
||||
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()
|
||||
self.refresh(layout=True)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return self._tree
|
||||
|
||||
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
|
||||
label = (
|
||||
Text(node.label, no_wrap=True, overflow="ellipsis")
|
||||
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"
|
||||
label.apply_meta(
|
||||
{
|
||||
"@click": f"click_label({node.id})",
|
||||
"tree_node": node.id,
|
||||
"cursor": node.is_cursor,
|
||||
}
|
||||
)
|
||||
return label
|
||||
|
||||
async def action_click_label(self, node_id: NodeID) -> None:
|
||||
node = self.nodes[node_id]
|
||||
self.cursor = 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")
|
||||
|
||||
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__":
|
||||
|
||||
|
||||
@@ -87,6 +87,11 @@ def test_point_blend():
|
||||
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():
|
||||
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user