diff --git a/examples/simple.py b/examples/simple.py index 22b2e544e..4398c64fc 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -5,7 +5,7 @@ from rich.markdown import Markdown from textual import events from textual.app import App -from textual.view import DockView +from textual.views import DockView from textual.widgets import Header, Footer, Placeholder, ScrollView @@ -16,13 +16,15 @@ logging.basicConfig( handlers=[FileHandler("richtui.log")], ) +log = logging.getLogger("rich") + class MyApp(App): """An example of a very simple Textual App""" async def on_load(self, event: events.Load) -> None: - await self.bind("q,ctrl+c", "quit") - await self.bind("b", "view.toggle('sidebar')") + await self.bind("q,ctrl+c", "quit", "Quit") + await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar") async def on_startup(self, event: events.Startup) -> None: @@ -31,7 +33,7 @@ class MyApp(App): footer = Footer() footer.add_key("b", "Toggle sidebar") footer.add_key("q", "Quit") - header = Header(self.title) + header = Header() body = ScrollView() sidebar = Placeholder() diff --git a/pyproject.toml b/pyproject.toml index 18c20565e..e1c6c725d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [tool.poetry] name = "textual" version = "0.1.5" +homepage = "https://github.com/willmcgugan/textual" description = "Text User Interface using Rich" authors = ["Will McGugan "] license = "MIT" diff --git a/src/textual/_event_broker.py b/src/textual/_event_broker.py new file mode 100644 index 000000000..d93bf1360 --- /dev/null +++ b/src/textual/_event_broker.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any, NamedTuple + + +class NoHandler(Exception): + pass + + +class HandlerArguments(NamedTuple): + modifiers: set[str] + action: str + + +def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArguments: + event_path = event_name.split(".") + for key, value in meta.items(): + if key.startswith("@"): + name_args = key[1:].split(".") + if name_args[: len(event_path)] == event_path: + modifiers = name_args[len(event_path) :] + return HandlerArguments(set(modifiers), value) + raise NoHandler(f"No handler for {event_name!r}") + + +if __name__ == "__main__": + + print(extract_handler_actions("mouse.down", {"@mouse.down.hot": "app.bell()"})) \ No newline at end of file diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index c60fd0119..90356b058 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -8,6 +8,7 @@ import signal import sys import logging import termios +from time import time import tty from typing import Any, TYPE_CHECKING from threading import Event, Thread @@ -197,6 +198,8 @@ class LinuxDriver(Driver): decode = utf8_decoder read = os.read + mouse_down_time = time() + log.debug("started key thread") try: while not self.exit_event.is_set(): @@ -205,7 +208,7 @@ class LinuxDriver(Driver): if mask | selectors.EVENT_READ: unicode_data = decode(read(fileno, 1024)) for event in parser.feed(unicode_data): - send_event(event) + self.process_event(event) except Exception: log.exception("error running key thread") finally: diff --git a/src/textual/_timer.py b/src/textual/_timer.py index aa8369033..815f65edc 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -76,7 +76,7 @@ class Timer: start = monotonic() try: while _repeat is None or count <= _repeat: - next_timer = start + (count * _interval) + next_timer = start + ((count + 1) * _interval) if self._skip and next_timer < monotonic(): count += 1 continue diff --git a/src/textual/_types.py b/src/textual/_types.py index 14f6b90b4..ff5f75e18 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -20,11 +20,17 @@ class MessageTarget(Protocol): async def post_message(self, message: "Message") -> bool: ... + def post_message_no_wait(self, message: "Message") -> bool: + ... + class EventTarget(Protocol): async def post_message(self, message: "Message") -> bool: ... + def post_message_no_wait(self, message: "Message") -> bool: + ... + MessageHandler = Callable[["Message"], Awaitable] diff --git a/src/textual/app.py b/src/textual/app.py index ab233dab5..f74fc0530 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -13,20 +13,24 @@ import rich.repr from rich.screen import Screen from rich import get_console from rich.console import Console, RenderableType +from rich.style import Style from rich.traceback import Traceback from . import events from . import actions from ._animator import Animator +from .binding import Bindings, NoBinding from .geometry import Point, Region from ._context import active_app +from ._event_broker import extract_handler_actions, NoHandler from .keys import Binding from .driver import Driver from .layouts.dock import DockLayout, Dock, DockEdge, DockOptions from ._linux_driver import LinuxDriver from .message_pump import MessagePump from .message import Message -from .view import DockView, View +from .view import View +from .views import DockView from .widget import Widget, Widget, Reactive log = logging.getLogger("rich") @@ -70,13 +74,12 @@ class App(MessagePump): console: Console = None, screen: bool = True, driver_class: Type[Driver] = None, - title: str = "Megasoma Application", + title: str = "Textual Application", ): - super().__init__() self.console = console or get_console() self._screen = screen self.driver_class = driver_class or LinuxDriver - self.title = title + self._title = title self._layout = DockLayout() self._view_stack: list[View] = [] self.children: set[MessagePump] = set() @@ -86,12 +89,17 @@ class App(MessagePump): self.mouse_captured: Widget | None = None self._driver: Driver | None = None - self._bindings: dict[str, Binding] = {} self._docks: list[Dock] = [] self._action_targets = {"app", "view"} self._animator = Animator(self) self.animate = self._animator.bind(self) self.mouse_position = Point(0, 0) + self.bindings = Bindings() + self._title = title + super().__init__() + + title: Reactive[str] = Reactive("Textual") + sub_title: Reactive[str] = Reactive("") def __rich_repr__(self) -> rich.repr.RichReprResult: yield "title", self.title @@ -107,14 +115,10 @@ class App(MessagePump): def view(self) -> View: return self._view_stack[-1] - @property - def bindings(self) -> dict[str, Binding]: - return self._bindings - - async def bind(self, keys: str, action: str, description: str = "") -> None: - all_keys = [key.strip() for key in keys.split(",")] - for key in all_keys: - self._bindings[key] = Binding(action, description) + async def bind( + self, keys: str, action: str, description: str = "", show: bool = False + ) -> None: + self.bindings.bind(keys, action, description, show=show) @classmethod def run( @@ -211,6 +215,7 @@ class App(MessagePump): try: driver.start_application_mode() + self.title = self._title await self.post_message(events.Startup(sender=self)) self.require_layout() except Exception: @@ -245,7 +250,6 @@ class App(MessagePump): self.view.require_layout() async def call_later(self, callback: Callable, *args, **kwargs) -> None: - await self.post_message(events.Idle(self)) await self.post_message( events.Callback(self, partial(callback, *args, **kwargs)) ) @@ -295,8 +299,11 @@ class App(MessagePump): async def on_event(self, event: events.Event) -> None: if isinstance(event, events.Key): - binding = self._bindings.get(event.key, None) - if binding is not None: + try: + binding = self.bindings.get_key(event.key) + except NoBinding: + pass + else: await self.action(binding.action) return @@ -310,7 +317,10 @@ class App(MessagePump): await super().on_event(event) async def action( - self, action: str, default_namespace: object | None = None + self, + action: str, + default_namespace: object | None = None, + modifiers: set[str] | None = None, ) -> None: """Perform an action. @@ -338,8 +348,21 @@ class App(MessagePump): if method is not None: await method(*params) - async def on_callback(self, event: events.Callback) -> None: - await event.callback() + async def broker_event( + self, event_name: str, event: events.Event, default_namespace: object | None + ) -> bool: + try: + style = getattr(event, "style") + except AttributeError: + return False + try: + modifiers, action = extract_handler_actions(event_name, style.meta) + except NoHandler: + return False + await self.action( + action, default_namespace=default_namespace, modifiers=modifiers + ) + return True async def on_shutdown_request(self, event: events.ShutdownRequest) -> None: log.debug("shutdown request") @@ -403,7 +426,7 @@ if __name__ == "__main__": show_bar: Reactive[bool] = Reactive(False) - def watch_show_bar(self, show_bar: bool) -> None: + async def watch_show_bar(self, show_bar: bool) -> None: self.animator.animate(self.bar, "layout_offset_x", -40 if show_bar else 0) async def action_toggle_sidebar(self) -> None: @@ -413,7 +436,7 @@ if __name__ == "__main__": view = await self.push_view(DockView()) - header = Header(self.title) + header = Header() footer = Footer() self.bar = Placeholder(name="left") footer.add_key("b", "Toggle sidebar") diff --git a/src/textual/binding.py b/src/textual/binding.py new file mode 100644 index 000000000..3bac175d2 --- /dev/null +++ b/src/textual/binding.py @@ -0,0 +1,58 @@ +from __future__ import annotations +from dataclasses import dataclass + + +class NoBinding(Exception): + """A binding was not found.""" + + +@dataclass +class Binding: + key: str + action: str + description: str + show: bool = False + + +class Bindings: + """Manage a set of bindings.""" + + def __init__(self) -> None: + self.keys: dict[str, Binding] = {} + + def shown_keys(self) -> list[Binding]: + keys = [binding for binding in self.keys.values() if binding.show] + return keys + + def bind( + self, keys: str, action: str, description: str = "", show: bool = False + ) -> None: + all_keys = [key.strip() for key in keys.split(",")] + for key in all_keys: + self.keys[key] = Binding(key, action, description, show=show) + + def get_key(self, key: str) -> Binding: + try: + return self.keys[key] + except KeyError: + raise NoBinding(f"No binding for {key}") + + +class BindingStack: + """Manage a stack of bindings.""" + + def __init__(self, *bindings: Bindings) -> None: + self._stack: list[Bindings] = list(bindings) + + def push(self, bindings: Bindings) -> None: + self._stack.append(bindings) + + def pop(self) -> Bindings: + return self._stack.pop() + + def get_key(self, key: str) -> Binding: + for bindings in reversed(self._stack): + binding = bindings.keys.get(key, None) + if binding is not None: + return binding + raise NoBinding(f"No binding for {key}") \ No newline at end of file diff --git a/src/textual/driver.py b/src/textual/driver.py index e8fde967e..d099618e6 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -1,10 +1,13 @@ from __future__ import annotations +import asyncio import logging +from time import time import platform from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from . import events from ._types import MessageTarget if TYPE_CHECKING: @@ -20,6 +23,27 @@ class Driver(ABC): def __init__(self, console: "Console", target: "MessageTarget") -> None: self.console = console self._target = target + self._loop = asyncio.get_event_loop() + self._mouse_down_time = time() + + def send_event(self, event: events.Event) -> None: + asyncio.run_coroutine_threadsafe( + self._target.post_message(event), loop=self._loop + ) + + def process_event(self, event: events.Event) -> None: + """Performs some additional processing of events.""" + if isinstance(event, events.MouseDown): + self._mouse_down_time = event.time + + self.send_event(event) + + if ( + isinstance(event, events.MouseUp) + and event.time - self._mouse_down_time <= 0.5 + ): + click_event = events.Click.from_event(event) + self.send_event(click_event) @abstractmethod def start_application_mode(self) -> None: diff --git a/src/textual/events.py b/src/textual/events.py index c09a86be6..eaab5ca90 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -1,16 +1,18 @@ from __future__ import annotations -from typing import Awaitable, Callable, TYPE_CHECKING +from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar import rich.repr from rich.style import Style -from .geometry import Point +from .geometry import Point, Dimensions from .message import Message from ._types import MessageTarget from .keys import Keys +MouseEventT = TypeVar("MouseEventT", bound="MouseEvent") + if TYPE_CHECKING: from ._timer import Timer as TimerClass from ._timer import TimerCallback @@ -32,13 +34,16 @@ class Null(Event): @rich.repr.auto -class Callback(Event): +class Callback(Event, bubble=False): def __init__( self, sender: MessageTarget, callback: Callable[[], Awaitable] ) -> None: self.callback = callback super().__init__(sender) + def __rich_repr__(self) -> rich.repr.RichReprResult: + yield "callback", self.callback + class ShutdownRequest(Event): pass @@ -89,6 +94,10 @@ class Resize(Event): self.height = height super().__init__(sender) + @property + def size(self) -> Dimensions: + return Dimensions(self.width, self.height) + def __rich_repr__(self) -> rich.repr.RichReprResult: yield self.width yield self.height @@ -182,6 +191,24 @@ class MouseEvent(InputEvent): self.screen_y = y if screen_y is None else screen_y self._style = style or Style() + @classmethod + def from_event(cls: Type[MouseEventT], event: MouseEvent) -> MouseEventT: + new_event = cls( + event.sender, + event.x, + event.y, + event.delta_x, + event.delta_y, + event.button, + event.shift, + event.meta, + event.ctrl, + event.screen_x, + event.screen_y, + event._style, + ) + return new_event + def __rich_repr__(self) -> rich.repr.RichReprResult: yield "x", self.x yield "y", self.y @@ -195,7 +222,6 @@ class MouseEvent(InputEvent): yield "shift", self.shift, False yield "meta", self.meta, False yield "ctrl", self.ctrl, False - yield "style", self._style, None @property def style(self) -> Style: diff --git a/src/textual/keys.py b/src/textual/keys.py index 5b43323c1..662ac2fe3 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -203,3 +203,4 @@ class Keys(str, Enum): class Binding: action: str description: str + show: bool = False diff --git a/src/textual/layout.py b/src/textual/layout.py index 56383e9a8..b67d5c4d8 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -31,12 +31,14 @@ class NoWidget(Exception): pass -class MapRegion(NamedTuple): +class OrderedRegion(NamedTuple): region: Region order: tuple[int, int] class ReflowResult(NamedTuple): + """The result of a reflow operation. Describes the chances to widgets.""" + hidden: set[Widget] shown: set[Widget] resized: set[Widget] @@ -66,7 +68,7 @@ class Layout(ABC): """Responsible for arranging Widgets in a view.""" def __init__(self) -> None: - self._layout_map: dict[Widget, MapRegion] = {} + self._layout_map: dict[Widget, OrderedRegion] = {} self.width = 0 self.height = 0 self.renders: dict[Widget, tuple[Region, Lines]] = {} @@ -103,7 +105,9 @@ class Layout(ABC): new_renders = { widget: (region, self.renders[widget][1]) for widget, (region, _order) in map.items() - if widget in self.renders and self.renders[widget][0].size == region.size + if widget in self.renders + and self.renders[widget][0].size == region.size + and not widget.check_repaint() } self.renders = new_renders @@ -121,11 +125,11 @@ class Layout(ABC): @abstractmethod def generate_map( self, width: int, height: int, offset: Point = Point(0, 0) - ) -> dict[Widget, MapRegion]: + ) -> dict[Widget, OrderedRegion]: ... @property - def map(self) -> dict[Widget, MapRegion]: + def map(self) -> dict[Widget, OrderedRegion]: return self._layout_map def __iter__(self) -> Iterable[tuple[Widget, Region]]: @@ -135,6 +139,11 @@ class Layout(ABC): for widget, (region, _) in layers: yield widget, region + def __reversed__(self) -> Iterable[tuple[Widget, Region]]: + layers = sorted(self._layout_map.items(), key=lambda item: item[1].order) + for widget, (region, _) in layers: + yield widget, region + def get_offset(self, widget: Widget) -> Point: try: return self._layout_map[widget].region.origin diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 5d03700d5..c795b1c9c 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Mapping, Sequence from rich._ratio import ratio_resolve from ..geometry import Region, Point -from ..layout import Layout, MapRegion +from ..layout import Layout, OrderedRegion from .._types import Lines if sys.version_info >= (3, 8): @@ -52,17 +52,17 @@ class DockLayout(Layout): def generate_map( self, width: int, height: int, offset: Point = Point(0, 0) - ) -> dict[Widget, MapRegion]: + ) -> dict[Widget, OrderedRegion]: from ..view import View - map: dict[Widget, MapRegion] = {} + map: dict[Widget, OrderedRegion] = {} layout_region = Region(0, 0, width, height) layers: dict[int, Region] = defaultdict(lambda: layout_region) def add_widget(widget: Widget, region: Region, order: tuple[int, int]): region = region + offset + widget.layout_offset - map[widget] = MapRegion(region, order) + map[widget] = OrderedRegion(region, order) if isinstance(widget, View): sub_map = widget.layout.generate_map( region.width, region.height, offset=region.origin diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 84eac59ce..460cab8fb 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -216,15 +216,29 @@ class MessagePump: await self._message_queue.put(message) return True + def post_message_from_child_no_wait(self, message: Message) -> bool: + if self._closing or self._closed: + return False + return self.post_message_no_wait(message) + async def post_message_from_child(self, message: Message) -> bool: if self._closing or self._closed: return False return await self.post_message(message) + async def on_callback(self, event: events.Callback) -> None: + await event.callback() + + def emit_no_wait(self, message: Message) -> bool: + if self._parent: + return self._parent.post_message_from_child_no_wait(message) + else: + log.warning("NO PARENT %r %r", self, message) + return False + async def emit(self, message: Message) -> bool: if self._parent: - await self._parent.post_message_from_child(message) - return True + return await self._parent.post_message_from_child(message) else: log.warning("NO PARENT %r %r", self, message) return False diff --git a/src/textual/page.py b/src/textual/page.py index f8825e24a..b7837bdba 100644 --- a/src/textual/page.py +++ b/src/textual/page.py @@ -5,19 +5,28 @@ from rich.padding import Padding, PaddingDimensions from rich.segment import Segment from rich.style import StyleType +from . import events from .geometry import Dimensions, Point +from .message import Message from .widget import Widget, Reactive +class PageUpdate(Message): + def can_batch(self, message: "Message") -> bool: + return isinstance(message, PageUpdate) + + class PageRender: def __init__( self, + page: Page, renderable: RenderableType, width: int | None = None, height: int | None = None, style: StyleType = "", padding: PaddingDimensions = 1, ) -> None: + self.page = page self.renderable = renderable self.width = width self.height = height @@ -51,6 +60,7 @@ class PageRender: renderable = Padding(renderable, self.padding) self._lines[:] = console.render_lines(renderable, options, style=style) self.size = Dimensions(width, len(self._lines)) + self.page.emit_no_wait(PageUpdate(self.page)) def __rich_console__( self, console: Console, options: ConsoleOptions @@ -78,7 +88,7 @@ class Page(Widget): def __init__( self, renderable: RenderableType, name: str = None, style: StyleType = "" ): - self._page = PageRender(renderable, style=style) + self._page = PageRender(self, renderable, style=style) super().__init__(name=name) x: Reactive[int] = Reactive(0) @@ -91,7 +101,7 @@ class Page(Widget): def validate_y(self, value: int) -> int: return max(0, value) - def watch_y(self, new: int) -> None: + async def watch_y(self, new: int) -> None: x, y = self._page.offset self._page.offset = Point(x, new) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 93924c5cc..8d049bfca 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -1,9 +1,30 @@ from __future__ import annotations +from functools import partial import sys -from typing import Callable, Generic, TypeVar +from typing import ( + Any, + Awaitable, + Callable, + Generic, + Type, + Union, + TypeVar, + TYPE_CHECKING, +) +from weakref import WeakSet + +from . import events from .message_pump import MessagePump +from ._types import MessageTarget + +if TYPE_CHECKING: + from .message import Message + from .app import App + from .widget import Widget + + Reactable = Union[Widget, App] if sys.version_info >= (3, 8): from typing import Protocol @@ -11,14 +32,6 @@ else: from typing_extensions import Protocol -class Reactable(Protocol): - def require_layout(self): - ... - - def require_repaint(self): - ... - - ReactiveType = TypeVar("ReactiveType") @@ -35,8 +48,9 @@ class Reactive(Generic[ReactiveType]): self._default = default self.layout = layout self.repaint = repaint + self._first = True - def __set_name__(self, owner: Reactable, name: str) -> None: + def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: self.name = name self.internal_name = f"__{name}" setattr(owner, self.internal_name, self._default) @@ -45,21 +59,51 @@ class Reactive(Generic[ReactiveType]): return getattr(obj, self.internal_name) def __set__(self, obj: Reactable, value: ReactiveType) -> None: - if getattr(obj, self.internal_name) != value: - current_value = getattr(obj, self.internal_name, None) - validate_function = getattr(obj, f"validate_{self.name}", None) - if callable(validate_function): - value = validate_function(value) + name = self.name + internal_name = f"__{name}" + current_value = getattr(obj, internal_name, None) + validate_function = getattr(obj, f"validate_{name}", None) + if callable(validate_function): + value = validate_function(value) - if current_value != value: + if current_value != value or self._first: + self._first = False + setattr(obj, internal_name, value) - watch_function = getattr(obj, f"watch_{self.name}", None) - if callable(watch_function): - watch_function(value) - setattr(obj, self.internal_name, value) + self.check_watchers(obj, name) - if self.layout: - obj.require_layout() - elif self.repaint: - obj.require_repaint() + if self.layout: + obj.require_layout() + elif self.repaint: + obj.require_repaint() + + @classmethod + def check_watchers(cls, obj: Reactable, name: str) -> None: + + internal_name = f"__{name}" + value = getattr(obj, internal_name) + + watch_function = getattr(obj, f"watch_{name}", None) + if callable(watch_function): + obj.post_message_no_wait( + events.Callback(obj, callback=partial(watch_function, value)) + ) + + watcher_name = f"__{name}_watchers" + watchers = getattr(obj, watcher_name, ()) + for watcher in watchers: + obj.post_message_no_wait( + events.Callback(obj, callback=partial(watcher, value)) + ) + + +def watch( + obj: Reactable, attribute_name: str, callback: Callable[[Any], Awaitable] +) -> None: + watcher_name = f"__{attribute_name}_watchers" + if not hasattr(obj, watcher_name): + setattr(obj, watcher_name, WeakSet()) + watchers = getattr(obj, watcher_name) + watchers.add(callback) + Reactive.check_watchers(obj, attribute_name) \ No newline at end of file diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index d7494d7b3..7f3ab4095 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -48,7 +48,7 @@ class ScrollBarRender: def __init__( self, virtual_size: int = 100, - window_size: int = 25, + window_size: int = 0, position: float = 0, thickness: int = 1, vertical: bool = True, @@ -95,7 +95,7 @@ class ScrollBarRender: _Style = Style blank = " " * width_thickness - foreground_meta = {"@click": "release", "@mouse.down": "grab"} + foreground_meta = {"@mouse.up": "release", "@mouse.down": "grab"} if window_size and size and virtual_size: step_size = virtual_size / size @@ -177,7 +177,7 @@ class ScrollBar(Widget): super().__init__(name=name) virtual_size: Reactive[int] = Reactive(100) - window_size: Reactive[int] = Reactive(20) + window_size: Reactive[int] = Reactive(0) position: Reactive[int] = Reactive(0) mouse_over: Reactive[bool] = Reactive(False) grabbed: Reactive[Point | None] = Reactive(None) diff --git a/src/textual/view.py b/src/textual/view.py index 9f9f6396f..63422abad 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -2,9 +2,8 @@ from __future__ import annotations from abc import ABC, abstractmethod from itertools import chain -from time import time import logging -from typing import cast, Iterable, Optional, Tuple, TYPE_CHECKING +from typing import Iterable, TYPE_CHECKING from rich.console import Console, ConsoleOptions, RenderResult, RenderableType import rich.repr @@ -12,7 +11,7 @@ from rich.style import Style from . import events from .layout import Layout, NoWidget -from .layouts.dock import DockEdge, DockLayout, Dock +from .layouts.dock import DockLayout from .geometry import Dimensions, Point, Region from .messages import UpdateMessage, LayoutMessage @@ -91,7 +90,7 @@ class View(Widget): async def refresh_layout(self) -> None: - if not self.size: + if not self.size or not self.is_root_view: return width, height = self.console.size @@ -99,15 +98,15 @@ class View(Widget): self.app.refresh() for widget in hidden: - await widget.post_message(events.Hide(self)) + widget.post_message_no_wait(events.Hide(self)) for widget in shown: - await widget.post_message(events.Show(self)) + widget.post_message_no_wait(events.Show(self)) send_resize = shown send_resize.update(resized) for widget, region in self.layout: if widget in send_resize: - await widget.post_message( + widget.post_message_no_wait( events.Resize(self, region.width, region.height) ) @@ -194,37 +193,3 @@ class View(Widget): widget.visible = not widget.visible await self.post_message(LayoutMessage(self)) # await self.refresh_layout() - - -class DoNotSet: - pass - - -do_not_set = DoNotSet() - - -class DockView(View): - def __init__(self, name: str | None = None) -> None: - super().__init__(layout=DockLayout(), name=name) - - async def dock( - self, - *widgets: Widget, - edge: DockEdge = "top", - z: int = 0, - size: int | None | DoNotSet = do_not_set, - name: str | None = None - ) -> None: - - dock = Dock(edge, widgets, z) - assert isinstance(self.layout, DockLayout) - self.layout.docks.append(dock) - for widget in widgets: - if size is not do_not_set: - widget.layout_size = cast(Optional[int], size) - if not self.is_mounted(widget): - if name is None: - await self.mount(widget) - else: - await self.mount(**{name: widget}) - await self.refresh_layout() diff --git a/src/textual/views/__init__.py b/src/textual/views/__init__.py new file mode 100644 index 000000000..4d8e74ff2 --- /dev/null +++ b/src/textual/views/__init__.py @@ -0,0 +1 @@ +from ._dock_view import DockView, Dock, DockEdge \ No newline at end of file diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py new file mode 100644 index 000000000..35d470bb8 --- /dev/null +++ b/src/textual/views/_dock_view.py @@ -0,0 +1,40 @@ +from __future__ import annotations +from typing import cast, Optional + +from ..layouts.dock import DockLayout, Dock, DockEdge +from ..view import View +from ..widget import Widget + + +class DoNotSet: + pass + + +do_not_set = DoNotSet() + + +class DockView(View): + def __init__(self, name: str | None = None) -> None: + super().__init__(layout=DockLayout(), name=name) + + async def dock( + self, + *widgets: Widget, + edge: DockEdge = "top", + z: int = 0, + size: int | None | DoNotSet = do_not_set, + name: str | None = None + ) -> None: + + dock = Dock(edge, widgets, z) + assert isinstance(self.layout, DockLayout) + self.layout.docks.append(dock) + for widget in widgets: + if size is not do_not_set: + widget.layout_size = cast(Optional[int], size) + if not self.is_mounted(widget): + if name is None: + await self.mount(widget) + else: + await self.mount(**{name: widget}) + await self.refresh_layout() diff --git a/src/textual/widget.py b/src/textual/widget.py index a5ab20bf4..d8a87c138 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3,36 +3,30 @@ from __future__ import annotations from functools import partial from logging import getLogger from typing import ( - Callable, - cast, - ClassVar, - Generic, - Iterable, - NewType, - TypeVar, + Any, TYPE_CHECKING, + Callable, + ClassVar, + NewType, + cast, ) +from weakref import WeakValueDictionary -from rich.align import Align - -from rich.console import Console, RenderableType -from rich.pretty import Pretty -from rich.panel import Panel import rich.repr -from rich.segment import Segment +from rich.align import Align +from rich.console import Console, RenderableType +from rich.panel import Panel +from rich.pretty import Pretty from rich.style import Style from . import events from ._animator import BoundAnimator from ._context import active_app -from ._loop import loop_last -from ._line_cache import LineCache +from .geometry import Dimensions from .message import Message -from .messages import UpdateMessage, LayoutMessage from .message_pump import MessagePump -from .geometry import Point, Dimensions -from .reactive import Reactive - +from .messages import LayoutMessage, UpdateMessage +from .reactive import Reactive, watch if TYPE_CHECKING: from .app import App @@ -65,6 +59,7 @@ class Widget(MessagePump): self._repaint_required = False self._layout_required = False self._animate: BoundAnimator | None = None + self._reactive_watches: dict[str, Callable] = {} super().__init__() @@ -85,6 +80,9 @@ class Widget(MessagePump): def __rich__(self) -> RenderableType: return self.render() + def watch(self, attribute_name, callback: Callable[[Any], None]) -> None: + watch(self, attribute_name, callback) + @property def is_visual(self) -> bool: return True @@ -207,17 +205,27 @@ class Widget(MessagePump): async def release_mouse(self) -> None: await self.app.capture_mouse(None) + async def broker_event(self, event_name: str, event: events.Event) -> bool: + return await self.app.broker_event(event_name, event, default_namespace=self) + async def dispatch_key(self, event: events.Key) -> None: + """Dispatch a key event to method. + + This method will call the method named 'key_' if it exists. + + Args: + event (events.Key): A key event. + """ + key_method = getattr(self, f"key_{event.key}", None) if key_method is not None: await key_method() async def on_mouse_down(self, event: events.MouseUp) -> None: - if "@mouse.down" in event.style.meta: - await self.app.action( - event.style.meta["@mouse.down"], default_namespace=self - ) + await self.broker_event("mouse.down", event) async def on_mouse_up(self, event: events.MouseUp) -> None: - if "@click" in event.style.meta: - await self.app.action(event.style.meta["@click"], default_namespace=self) + await self.broker_event("mouse.up", event) + + async def on_click(self, event: events.Click) -> None: + await self.broker_event("click", event) diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 4dcf3f796..7aa1ff865 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -10,6 +10,7 @@ from rich.text import TextType from .. import events from ..widget import Widget +from ..reactive import watch, Reactive log = getLogger("rich") @@ -17,43 +18,59 @@ log = getLogger("rich") class Header(Widget): def __init__( self, - title: TextType, *, - panel: bool = True, + tall: bool = True, style: StyleType = "white on blue", - clock: bool = True + clock: bool = True, ) -> None: - self.title = title - self.panel = panel + super().__init__() + self.tall = tall self.style = style self.clock = clock - super().__init__() - self.layout_size = 3 + tall: Reactive[bool] = Reactive(True, layout=True) + style: Reactive[StyleType] = Reactive("white on blue") + clock: Reactive[bool] = Reactive(True) + title: Reactive[str] = Reactive("") + sub_title: Reactive[str] = Reactive("") + + @property + def full_title(self) -> str: + return f"{self.title} - {self.sub_title}" if self.sub_title else self.title def __rich_repr__(self) -> RichReprResult: yield self.title + async def watch_tall(self, tall: bool) -> None: + self.layout_size = 3 if tall else 1 + def get_clock(self) -> str: return datetime.now().time().strftime("%X") def render(self) -> RenderableType: - header_table = Table.grid(padding=(0, 1), expand=True) header_table.style = self.style - header_table.add_column(justify="left", ratio=0) + header_table.add_column(justify="left", ratio=0, width=8) header_table.add_column("title", justify="center", ratio=1) - if self.clock: - header_table.add_column("clock", justify="right") - header_table.add_row("🐞", self.title, self.get_clock()) - else: - header_table.add_row("🐞", self.title) + header_table.add_column("clock", justify="right", width=8) + header_table.add_row( + "🐞", self.full_title, self.get_clock() if self.clock else "" + ) header: RenderableType - if self.panel: - header = Panel(header_table, style=self.style) - else: - header = header_table + header = Panel(header_table, style=self.style) if self.tall else header_table return header async def on_mount(self, event: events.Mount) -> None: self.set_interval(1.0, callback=self.refresh) + + async def set_title(title: str) -> None: + self.title = title + + async def set_sub_title(sub_title: str) -> None: + self.sub_title = sub_title + + watch(self.app, "title", set_title) + watch(self.app, "sub_title", set_sub_title) + + async def on_click(self, event: events.Click) -> None: + self.tall = not self.tall diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 078c544db..5193a0b4c 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -7,11 +7,12 @@ from rich.style import StyleType from .. import events +from ..geometry import Dimensions from ..message import Message -from ..scrollbar import ScrollTo, ScrollBar, ScrollDown, ScrollUp +from ..scrollbar import ScrollTo, ScrollBar from ..geometry import clamp from ..page import Page -from ..view import DockView +from ..views import DockView from ..reactive import Reactive @@ -30,7 +31,7 @@ class ScrollView(DockView): self.fluid = fluid self._vertical_scrollbar = ScrollBar(vertical=True) self._page = Page(renderable or "", style=style) - super().__init__(name="ScrollView") + super().__init__(name=name) x: Reactive[float] = Reactive(0) y: Reactive[float] = Reactive(0) @@ -43,7 +44,7 @@ class ScrollView(DockView): def validate_target_y(self, value: float) -> float: return clamp(value, 0, self._page.contents_size.height - self.size.height) - def watch_y(self, new_value: float) -> None: + async def watch_y(self, new_value: float) -> None: self._page.y = round(new_value) self._vertical_scrollbar.position = round(new_value) @@ -55,11 +56,6 @@ class ScrollView(DockView): await self.dock(self._vertical_scrollbar, edge="right", size=1) await self.dock(self._page, edge="top") - async def on_idle(self, event: events.Idle) -> None: - self._vertical_scrollbar.virtual_size = self._page.virtual_size.height - self._vertical_scrollbar.window_size = self.size.height - await super().on_idle(event) - def scroll_up(self) -> None: self.target_y += 1.5 self.animate("y", self.target_y, easing="out_cubic", speed=80) @@ -110,9 +106,9 @@ class ScrollView(DockView): self.animate("y", self.target_y, duration=1, easing="out_cubic") async def on_resize(self, event: events.Resize) -> None: + await super().on_resize(event) if self.fluid: self._page.update() - await super().on_resize(event) async def message_scroll_up(self, message: Message) -> None: self.page_up() @@ -125,4 +121,9 @@ class ScrollView(DockView): self.target_x = message.x if message.y is not None: self.target_y = message.y - self.animate("y", self.target_y, speed=100, easing="out_cubic") \ No newline at end of file + self.animate("y", self.target_y, speed=150, easing="out_cubic") + + async def message_page_update(self, message: Message) -> None: + self.y = self.validate_y(self.y) + self._vertical_scrollbar.virtual_size = self._page.virtual_size.height + self._vertical_scrollbar.window_size = self.size.height