mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -5,10 +5,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.1.10] - Unreleased
|
||||
## [0.1.10] - 2021-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added keyboard control of tree control
|
||||
- Added Widget.gutter to calculate space between renderable and outside edge
|
||||
- Added margin, padding, and border attributes to Widget
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -7,7 +7,7 @@ class Colorizer(App):
|
||||
await self.bind("g", "color('green')")
|
||||
await self.bind("b", "color('blue')")
|
||||
|
||||
async def action_color(self, color: str) -> None:
|
||||
def action_color(self, color: str) -> None:
|
||||
self.background = f"on {color}"
|
||||
|
||||
|
||||
|
||||
@@ -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=48, 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:
|
||||
|
||||
@@ -44,7 +44,7 @@ class Bindings:
|
||||
description,
|
||||
show=show,
|
||||
key_display=key_display,
|
||||
allow_forward=True,
|
||||
allow_forward=allow_forward,
|
||||
)
|
||||
|
||||
def get_key(self, key: str) -> Binding:
|
||||
|
||||
@@ -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:
|
||||
@@ -189,7 +189,6 @@ class Region(NamedTuple):
|
||||
Returns:
|
||||
tuple[int, int]: [description]
|
||||
"""
|
||||
return (self.x, self.x + self.width)
|
||||
return (self.y, self.y + self.height)
|
||||
|
||||
@property
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -3,12 +3,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from asyncio import CancelledError
|
||||
from asyncio import Queue, QueueEmpty, Task
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Awaitable, Iterable, Callable
|
||||
from weakref import WeakSet
|
||||
|
||||
from rich.traceback import Traceback
|
||||
|
||||
from . import events
|
||||
from . import log
|
||||
from ._timer import Timer, TimerCallback
|
||||
@@ -174,7 +171,7 @@ class MessagePump:
|
||||
except CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._runnning = False
|
||||
self._running = False
|
||||
|
||||
async def _process_messages(self) -> None:
|
||||
"""Process messages until the queue is closed."""
|
||||
@@ -195,6 +192,7 @@ class MessagePump:
|
||||
pending = self.peek_message()
|
||||
if pending is None or not message.can_replace(pending):
|
||||
break
|
||||
# self.log(message, "replaced with", pending)
|
||||
try:
|
||||
message = await self.get_message()
|
||||
except MessagePumpClosed:
|
||||
|
||||
@@ -20,13 +20,25 @@ class UpdateMessage(Message, verbosity=3):
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.sender
|
||||
yield "widget"
|
||||
yield self.widget
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if isinstance(other, UpdateMessage):
|
||||
return self.widget == other.widget and self.layout == other.layout
|
||||
return NotImplemented
|
||||
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, UpdateMessage) and self.widget is message.widget
|
||||
return isinstance(message, UpdateMessage) and self == message
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
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)
|
||||
|
||||
@@ -242,8 +242,6 @@ class View(Widget):
|
||||
else:
|
||||
self.log("view.forwarded", event)
|
||||
await self.post_message(event)
|
||||
# if self.focused is not None:
|
||||
# await self.focused.forward_event(event)
|
||||
|
||||
async def action_toggle(self, name: str) -> None:
|
||||
widget = self.named_widgets[name]
|
||||
|
||||
@@ -13,7 +13,8 @@ from ..widgets import Static
|
||||
|
||||
|
||||
class WindowChange(Message):
|
||||
pass
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, WindowChange)
|
||||
|
||||
|
||||
class WindowView(View, layout=VerticalLayout):
|
||||
@@ -24,7 +25,6 @@ class WindowView(View, layout=VerticalLayout):
|
||||
gutter: tuple[int, int] = (0, 1),
|
||||
name: str | None = None
|
||||
) -> None:
|
||||
self.gutter = gutter
|
||||
layout = VerticalLayout(gutter=gutter)
|
||||
self.widget = widget if isinstance(widget, Widget) else Static(widget)
|
||||
layout.add(self.widget)
|
||||
@@ -40,6 +40,10 @@ class WindowView(View, layout=VerticalLayout):
|
||||
self.refresh(layout=True)
|
||||
await self.emit(WindowChange(self))
|
||||
|
||||
async def message_update(self, message: UpdateMessage) -> None:
|
||||
message.prevent_default()
|
||||
await self.emit(WindowChange(self))
|
||||
|
||||
async def watch_virtual_size(self, size: Size) -> None:
|
||||
await self.emit(WindowChange(self))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -39,10 +40,27 @@ if TYPE_CHECKING:
|
||||
log = getLogger("rich")
|
||||
|
||||
|
||||
class Spacing(NamedTuple):
|
||||
"""The spacing around a renderable."""
|
||||
|
||||
top: int = 0
|
||||
right: int = 0
|
||||
bottom: int = 0
|
||||
left: int = 0
|
||||
|
||||
|
||||
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):
|
||||
@@ -76,19 +94,19 @@ class Widget(MessagePump):
|
||||
layout_offset_y: Reactive[float] = Reactive(0.0, layout=True)
|
||||
|
||||
style: Reactive[str | None] = Reactive(None)
|
||||
padding: Reactive[PaddingDimensions | None] = Reactive(None, layout=True)
|
||||
margin: Reactive[PaddingDimensions | None] = Reactive(None, layout=True)
|
||||
padding: Reactive[Spacing | None] = Reactive(None, layout=True)
|
||||
margin: Reactive[Spacing | None] = Reactive(None, layout=True)
|
||||
border: Reactive[str] = Reactive("none", layout=True)
|
||||
border_style: Reactive[str] = Reactive("")
|
||||
border_title: Reactive[TextType] = Reactive("")
|
||||
|
||||
BOX_MAP = {"normal": box.SQUARE, "round": box.ROUNDED, "bold": box.HEAVY}
|
||||
|
||||
def validate_padding(self, padding: PaddingDimensions) -> tuple[int, int, int, int]:
|
||||
return Padding.unpack(padding)
|
||||
def validate_padding(self, padding: PaddingDimensions) -> Spacing:
|
||||
return Spacing(*Padding.unpack(padding))
|
||||
|
||||
def validate_margin(self, padding: PaddingDimensions) -> tuple[int, int, int, int]:
|
||||
return Padding.unpack(padding)
|
||||
def validate_margin(self, padding: PaddingDimensions) -> Spacing:
|
||||
return Spacing(*Padding.unpack(padding))
|
||||
|
||||
def validate_layout_offset_x(self, value) -> int:
|
||||
return int(value)
|
||||
@@ -161,39 +179,37 @@ class Widget(MessagePump):
|
||||
"""Get the layout offset as a tuple."""
|
||||
return (round(self.layout_offset_x), round(self.layout_offset_y))
|
||||
|
||||
@property
|
||||
def gutter(self) -> Spacing:
|
||||
mt, mr, mb, bl = self.margin or (0, 0, 0, 0)
|
||||
pt, pr, pb, pl = self.padding or (0, 0, 0, 0)
|
||||
border = 1 if self.border else 0
|
||||
gutter = Spacing(
|
||||
mt + pt + border, mr + pr + border, mb + pb + border, bl + pl + border
|
||||
)
|
||||
return gutter
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
width (int): [description]
|
||||
height (int): [description]
|
||||
|
||||
Returns:
|
||||
Lines: [description]
|
||||
"""
|
||||
"""Get segment lines to render the widget."""
|
||||
if self.render_cache is None:
|
||||
self.render_cache = self.render_lines()
|
||||
self.render_lines()
|
||||
assert self.render_cache is not None
|
||||
lines = self.render_cache.lines
|
||||
return lines
|
||||
|
||||
@@ -297,7 +313,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,36 @@ 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}
|
||||
return self.render_tree_label(
|
||||
node,
|
||||
node.data.is_dir,
|
||||
node.expanded,
|
||||
node.is_cursor,
|
||||
node.id == self.hover_node,
|
||||
self.has_focus,
|
||||
)
|
||||
|
||||
@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 +92,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,21 @@ 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.y = line
|
||||
elif line >= self.y + self.size.height:
|
||||
self.y = line - self.size.height + 1
|
||||
|
||||
def scroll_to_center(self, line: int) -> None:
|
||||
self.target_y = line - self.size.height // 2
|
||||
if abs(self.target_y - self.y) > 1:
|
||||
# Animate if its more than 1
|
||||
self.animate("y", self.target_y, easing="out_cubic")
|
||||
else:
|
||||
# Jump if its just one step
|
||||
self.y = self.target_y
|
||||
|
||||
async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
||||
self.scroll_up()
|
||||
|
||||
@@ -191,3 +203,7 @@ class ScrollView(View):
|
||||
self.refresh()
|
||||
if self.layout.show_row("hscroll", virtual_size.width > self.size.width):
|
||||
self.refresh()
|
||||
|
||||
def message_cursor_move(self, message: CursorMoveMessage) -> None:
|
||||
self.scroll_to_center(message.line)
|
||||
message.stop()
|
||||
|
||||
@@ -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, Iterator, 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,13 +27,15 @@ 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._node_id = node_id
|
||||
self.parent = parent
|
||||
self.id = node_id
|
||||
self._control = control
|
||||
self._tree = tree
|
||||
self.label = label
|
||||
@@ -41,10 +44,7 @@ class TreeNode(Generic[NodeDataType]):
|
||||
self._expanded = False
|
||||
self._empty = False
|
||||
self._tree.expanded = False
|
||||
|
||||
@property
|
||||
def id(self) -> NodeID:
|
||||
return self._node_id
|
||||
self.children: list[TreeNode] = []
|
||||
|
||||
@property
|
||||
def control(self) -> TreeControl:
|
||||
@@ -58,21 +58,96 @@ class TreeNode(Generic[NodeDataType]):
|
||||
def expanded(self) -> bool:
|
||||
return self._expanded
|
||||
|
||||
@property
|
||||
def is_cursor(self) -> bool:
|
||||
return self.control.cursor == self.id and self.control.show_cursor
|
||||
|
||||
@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:
|
||||
pass
|
||||
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)
|
||||
|
||||
async def add(self, label: TextType, data: NodeDataType) -> None:
|
||||
await self._control.add(self._node_id, label, data=data)
|
||||
self._control.refresh()
|
||||
await self._control.add(self.id, label, data=data)
|
||||
self._control.refresh(layout=True)
|
||||
self._empty = False
|
||||
|
||||
def __rich__(self) -> RenderableType:
|
||||
@@ -96,18 +171,29 @@ class TreeControl(Generic[NodeDataType], Widget):
|
||||
) -> None:
|
||||
self.data = data
|
||||
|
||||
self._node_id = NodeID(0)
|
||||
self.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
|
||||
None, self.id, self, self._tree, label, data
|
||||
)
|
||||
|
||||
self._tree.label = self.root
|
||||
self.nodes[NodeID(self._node_id)] = self.root
|
||||
self.nodes[NodeID(self.id)] = self.root
|
||||
super().__init__(name=name)
|
||||
self.padding = padding
|
||||
|
||||
hover_node: Reactive[NodeID | None] = Reactive(None)
|
||||
cursor: Reactive[NodeID] = Reactive(NodeID(0), layout=True)
|
||||
cursor_line: Reactive[int] = Reactive(0, repaint=False)
|
||||
show_cursor: Reactive[bool] = Reactive(False, layout=True)
|
||||
|
||||
def watch_show_cursor(self, value: bool) -> None:
|
||||
self.emit_no_wait(CursorMoveMessage(self, self.cursor_line))
|
||||
|
||||
def watch_cursor_line(self, value: int) -> None:
|
||||
if self.show_cursor:
|
||||
self.emit_no_wait(CursorMoveMessage(self, value + self.gutter.top))
|
||||
|
||||
async def add(
|
||||
self,
|
||||
@@ -116,36 +202,103 @@ class TreeControl(Generic[NodeDataType], Widget):
|
||||
data: NodeDataType,
|
||||
) -> None:
|
||||
parent = self.nodes[node_id]
|
||||
self._node_id = NodeID(self._node_id + 1)
|
||||
self.id = NodeID(self.id + 1)
|
||||
child_tree = parent._tree.add(label)
|
||||
child_node: TreeNode[NodeDataType] = TreeNode(
|
||||
self._node_id, self, child_tree, label, data
|
||||
parent, self.id, self, child_tree, label, data
|
||||
)
|
||||
parent.children.append(child_node)
|
||||
child_tree.label = child_node
|
||||
self.nodes[self._node_id] = child_node
|
||||
self.nodes[self.id] = child_node
|
||||
|
||||
self.refresh()
|
||||
self.refresh(layout=True)
|
||||
|
||||
def find_cursor(self) -> int | None:
|
||||
"""Find the line location for the cursor node."""
|
||||
|
||||
node_id = self.cursor
|
||||
line = 0
|
||||
|
||||
stack: list[Iterator[TreeNode[NodeDataType]]]
|
||||
stack = [iter([self.root])]
|
||||
|
||||
pop = stack.pop
|
||||
push = stack.append
|
||||
while stack:
|
||||
iter_children = pop()
|
||||
try:
|
||||
node = next(iter_children)
|
||||
except StopIteration:
|
||||
continue
|
||||
else:
|
||||
if node.id == node_id:
|
||||
return line
|
||||
line += 1
|
||||
push(iter_children)
|
||||
if node.children and node.expanded:
|
||||
push(iter(node.children))
|
||||
return None
|
||||
|
||||
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})
|
||||
return label
|
||||
|
||||
async def action_click_label(self, node_id: NodeID) -> None:
|
||||
node = self.nodes[node_id]
|
||||
self.cursor = node.id
|
||||
self.cursor_line = self.find_cursor() or 0
|
||||
self.show_cursor = False
|
||||
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:
|
||||
event.stop()
|
||||
await self.cursor_down()
|
||||
|
||||
async def key_up(self, event: events.Key) -> None:
|
||||
event.stop()
|
||||
await self.cursor_up()
|
||||
|
||||
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:
|
||||
if not self.show_cursor:
|
||||
self.show_cursor = True
|
||||
return
|
||||
cursor_node = self.nodes[self.cursor]
|
||||
next_node = cursor_node.next_node
|
||||
if next_node is not None:
|
||||
self.cursor_line += 1
|
||||
self.cursor = next_node.id
|
||||
|
||||
async def cursor_up(self) -> None:
|
||||
if not self.show_cursor:
|
||||
self.show_cursor = True
|
||||
return
|
||||
cursor_node = self.nodes[self.cursor]
|
||||
previous_node = cursor_node.previous_node
|
||||
if previous_node is not None:
|
||||
self.cursor_line -= 1
|
||||
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