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
|
# 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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user