From e0b9dde56316fd7b4d8846d53de9a30257db67ff Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Jul 2021 20:16:54 +0100 Subject: [PATCH] grab to scroll --- examples/simple.py | 37 +++++++++++------ src/textual/_animator.py | 5 ++- src/textual/_linux_driver.py | 2 + src/textual/_timer.py | 1 - src/textual/app.py | 55 ++++++++++++++++++------- src/textual/events.py | 30 +++++++++++++- src/textual/layout.py | 8 ++++ src/textual/message_pump.py | 16 +++++--- src/textual/scrollbar.py | 63 ++++++++++++++++++++++++----- src/textual/view.py | 34 +++++++++++----- src/textual/widget.py | 21 ++++++---- src/textual/widgets/_scroll_view.py | 29 ++++++++----- 12 files changed, 227 insertions(+), 74 deletions(-) diff --git a/examples/simple.py b/examples/simple.py index ca9fc2d41..22b2e544e 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,3 +1,6 @@ +import logging +from logging import FileHandler + from rich.markdown import Markdown from textual import events @@ -6,6 +9,14 @@ from textual.view import DockView from textual.widgets import Header, Footer, Placeholder, ScrollView +logging.basicConfig( + level="NOTSET", + format="%(message)s", + datefmt="[%X]", + handlers=[FileHandler("richtui.log")], +) + + class MyApp(App): """An example of a very simple Textual App""" @@ -14,25 +25,27 @@ class MyApp(App): await self.bind("b", "view.toggle('sidebar')") async def on_startup(self, event: events.Startup) -> None: + view = await self.push_view(DockView()) - header = Header(self.title) + footer = Footer() - sidebar = Placeholder(name="sidebar") - - with open("richreadme.md", "rt") as fh: - readme = Markdown(fh.read(), hyperlinks=True) - - body = ScrollView(readme) - footer.add_key("b", "Toggle sidebar") footer.add_key("q", "Quit") + header = Header(self.title) + body = ScrollView() + sidebar = Placeholder() await view.dock(header, edge="top") await view.dock(footer, edge="bottom") - await view.dock(sidebar, edge="left", size=30) + await view.dock(sidebar, edge="left", size=30, name="sidebar") await view.dock(body, edge="right") - self.require_layout() + + async def get_markdown(filename: str) -> None: + with open(filename, "rt") as fh: + readme = Markdown(fh.read(), hyperlinks=True) + await body.update(readme) + + await self.call_later(get_markdown, "richreadme.md") -app = MyApp(title="Simple App") -app.run() +MyApp.run(title="Simple App") diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 9c6a2be33..2be1c1c7f 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -120,12 +120,15 @@ class Animator: def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None: self._animations: dict[tuple[object, str], Animation] = {} self._timer = Timer(target, 1 / frames_per_second, target, callback=self) + self._timer_task: asyncio.Task | None = None async def start(self) -> None: - asyncio.get_event_loop().create_task(self._timer.run()) + self._timer_task = asyncio.get_event_loop().create_task(self._timer.run()) async def stop(self) -> None: self._timer.stop() + if self._timer_task: + await self._timer_task def bind(self, obj: object) -> BoundAnimator: return BoundAnimator(self, obj) diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index 01369547c..c60fd0119 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -50,8 +50,10 @@ class LinuxDriver(Driver): def _enable_mouse_support(self) -> None: write = self.console.file.write write("\x1b[?1000h") + write("\x1b[?1015h") write("\x1b[?1006h") + # write("\x1b[?1007h") self.console.file.flush() diff --git a/src/textual/_timer.py b/src/textual/_timer.py index 6d66f9f93..aa8369033 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -81,7 +81,6 @@ class Timer: count += 1 continue try: - if await wait_for(_wait(), max(0, next_timer - monotonic())): break except TimeoutError: diff --git a/src/textual/app.py b/src/textual/app.py index 23c50f0cc..ef2fbd792 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2,10 +2,10 @@ from __future__ import annotations import os import asyncio - +from functools import partial import logging import signal -from typing import Any, ClassVar, Type, TypeVar +from typing import Any, Callable, ClassVar, Type, TypeVar import warnings from rich.control import Control @@ -78,7 +78,7 @@ class App(MessagePump): self.driver_class = driver_class or LinuxDriver self.title = title self._layout = DockLayout() - self._view_stack: list[View] = [View()] + self._view_stack: list[View] = [] self.children: set[MessagePump] = set() self.focused: Widget | None = None @@ -117,7 +117,11 @@ class App(MessagePump): @classmethod def run( - cls, console: Console = None, screen: bool = True, driver: Type[Driver] = None + cls, + console: Console = None, + screen: bool = True, + driver: Type[Driver] = None, + **kwargs, ): """Run the app. @@ -128,8 +132,7 @@ class App(MessagePump): """ async def run_app() -> None: - app = cls(console=console, screen=screen, driver_class=driver) - + app = cls(console=console, screen=screen, driver_class=driver, **kwargs) await app.process_messages() asyncio.run(run_app()) @@ -137,7 +140,7 @@ class App(MessagePump): async def push_view(self, view: ViewType) -> ViewType: await self.register(view) view.set_parent(self) - self._view_stack[0] = view + self._view_stack.append(view) return view def on_keyboard_interupt(self) -> None: @@ -181,7 +184,13 @@ class App(MessagePump): async def capture_mouse(self, widget: Widget | None) -> None: """Send all Mouse events to a given widget.""" + if widget == self.mouse_captured: + return + if self.mouse_captured is not None: + await self.mouse_captured.post_message(events.MouseReleased(self)) self.mouse_captured = widget + if widget is not None: + await widget.post_message(events.MouseCaptured(self)) async def process_messages(self) -> None: log.debug("driver=%r", self.driver_class) @@ -189,16 +198,18 @@ class App(MessagePump): # loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt) driver = self._driver = self.driver_class(self.console, self) active_app.set(self) + + await self.push_view(View()) + self.view.set_parent(self) await self.register(self.view) - if hasattr(self, "on_load"): - await self.on_load(events.Load(sender=self)) - - await self.post_message(events.Startup(sender=self)) + await self.dispatch_message(events.Load(sender=self)) try: driver.start_application_mode() + await self.post_message(events.Startup(sender=self)) + self.require_layout() except Exception: self.console.print_exception() log.exception("error starting application mode") @@ -207,11 +218,19 @@ class App(MessagePump): await self.animator.start() try: await super().process_messages() + await self.animator.stop() + + while self.children: + child = self.children.pop() + log.debug("closing %r", child) + await child.close_messages() + + while self._view_stack: + view = self._view_stack.pop() + await view.close_messages() except Exception: traceback = Traceback(show_locals=True) - await self.animator.stop() - await self.view.close_messages() driver.stop_application_mode() if traceback is not None: self.console.print(traceback) @@ -222,6 +241,12 @@ class App(MessagePump): def require_layout(self) -> None: 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)) + ) + async def message_update(self, message: Message) -> None: self.refresh() @@ -308,8 +333,8 @@ class App(MessagePump): if method is not None: await method(*params) - async def on_load(self, event: events.Load) -> None: - pass + async def on_callback(self, event: events.Callback) -> None: + await event.callback() async def on_shutdown_request(self, event: events.ShutdownRequest) -> None: log.debug("shutdown request") diff --git a/src/textual/events.py b/src/textual/events.py index 4a12b3ffd..157d9c842 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Callable, TYPE_CHECKING from rich.repr import rich_repr, RichReprResult +from rich.style import Style from .message import Message from ._types import MessageTarget @@ -29,6 +30,13 @@ class Null(Event): return isinstance(message, Null) +@rich_repr +class Callback(Event): + def __init__(self, sender: MessageTarget, callback: Callable[[], None]) -> None: + self.callback = callback + super().__init__(sender) + + class ShutdownRequest(Event): pass @@ -99,6 +107,14 @@ class Hide(Event): """Widget has been hidden.""" +class MouseCaptured(Event): + """Mouse has been captured.""" + + +class MouseReleased(Event): + """Mouse has been released.""" + + class InputEvent(Event, bubble=True): pass @@ -132,6 +148,7 @@ class MouseEvent(InputEvent): ctrl: bool, screen_x: int | None = None, screen_y: int | None = None, + style: Style | None = None, ) -> None: super().__init__(sender) self.x = x @@ -144,6 +161,7 @@ class MouseEvent(InputEvent): self.ctrl = ctrl self.screen_x = x if screen_x is None else screen_x self.screen_y = y if screen_y is None else screen_y + self._style = style or Style() def __rich_repr__(self) -> RichReprResult: yield "x", self.x @@ -158,6 +176,15 @@ 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: + return self._style or Style() + + @style.setter + def style(self, style: Style) -> None: + self._style = style def offset(self, x: int, y: int): return self.__class__( @@ -172,6 +199,7 @@ class MouseEvent(InputEvent): ctrl=self.ctrl, screen_x=self.screen_x, screen_y=self.screen_y, + style=self.style, ) diff --git a/src/textual/layout.py b/src/textual/layout.py index 82fe99987..56383e9a8 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -164,6 +164,14 @@ class Layout(ABC): return segment.style or Style.null() return Style.null() + def get_widget_region(self, widget: Widget) -> Region: + try: + region, _ = self._layout_map[widget] + except KeyError: + raise NoWidget("Widget is not in layout") + else: + return region + @property def cuts(self) -> list[list[int]]: if self._cuts is not None: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 6ccd14a3a..84eac59ce 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1,15 +1,15 @@ from __future__ import annotations -from typing import Any, Coroutine, Awaitable, NamedTuple import asyncio -from asyncio import Event, Queue, Task, QueueEmpty - import logging +from asyncio import Event, Queue, QueueEmpty, Task +from typing import Any, Awaitable, Coroutine, NamedTuple +from weakref import WeakSet from . import events -from .message import Message from ._timer import Timer, TimerCallback from ._types import MessageHandler +from .message import Message log = logging.getLogger("rich") @@ -31,7 +31,7 @@ class MessagePump: self._disabled_messages: set[type[Message]] = set() self._pending_message: Message | None = None self._task: Task | None = None - self._child_tasks: set[Task] = set() + self._child_tasks: WeakSet[Task] = WeakSet() @property def task(self) -> Task: @@ -117,7 +117,7 @@ class MessagePump: timer = Timer( self, interval, self, name=name, callback=callback, repeat=repeat or None ) - asyncio.get_event_loop().create_task(timer.run()) + self._child_tasks.add(asyncio.get_event_loop().create_task(timer.run())) return timer async def close_messages(self, wait: bool = True) -> None: @@ -126,10 +126,13 @@ class MessagePump: return self._closing = True + await self._message_queue.put(None) for task in self._child_tasks: task.cancel() + await task + self._child_tasks.clear() def start_messages(self) -> None: self._task = asyncio.create_task(self.process_messages()) @@ -167,6 +170,7 @@ class MessagePump: idle_handler = getattr(self, "on_idle", None) if idle_handler is not None and not self._closed: await idle_handler(events.Idle(self)) + log.debug("CLOSED %r", self) async def dispatch_message(self, message: Message) -> bool | None: diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 9aae30b9d..2c49c9819 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -3,9 +3,8 @@ from __future__ import annotations import logging -from rich.repr import rich_repr, RichReprResult +import rich.repr from rich.color import Color -from rich.style import Style from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.segment import Segment, Segments from rich.style import Style, StyleType @@ -13,16 +12,35 @@ from rich.style import Style, StyleType log = logging.getLogger("rich") from . import events +from ._types import MessageTarget from .message import Message from .widget import Reactive, Widget +@rich.repr.auto class ScrollUp(Message, bubble=True): - pass + """Message sent when clicking above handle.""" +@rich.repr.auto class ScrollDown(Message, bubble=True): - pass + """Message sent when clicking below handle.""" + + +@rich.repr.auto +class ScrollRelative(Message, bubble=True): + """Message sent when click and dragging handle.""" + + def __init__( + self, sender: MessageTarget, x: float | None = None, y: float | None = None + ) -> None: + self.x = x + self.y = y + super().__init__(sender) + + def __rich_repr__(self) -> rich.repr.RichReprResult: + yield "x", self.x, None + yield "y", self.y, None class ScrollBarRender: @@ -76,7 +94,7 @@ class ScrollBarRender: _Style = Style blank = " " * width_thickness - foreground_meta = {"background": False} + foreground_meta = {"@click": "release", "@mouse.down": "grab"} if window_size and size and virtual_size: step_size = virtual_size / size @@ -150,7 +168,7 @@ class ScrollBarRender: yield bar -@rich_repr +@rich.repr.auto class ScrollBar(Widget): def __init__(self, vertical: bool = True, name: str | None = None) -> None: self.vertical = vertical @@ -160,21 +178,24 @@ class ScrollBar(Widget): window_size: Reactive[int] = Reactive(20) position: Reactive[int] = Reactive(0) mouse_over: Reactive[bool] = Reactive(False) + grabbed: Reactive[bool] = Reactive(False) - def __rich_repr__(self) -> RichReprResult: + def __rich_repr__(self) -> rich.repr.RichReprResult: yield "virtual_size", self.virtual_size yield "window_size", self.window_size yield "position", self.position def render(self) -> RenderableType: + style = Style( + bgcolor=(Color.parse("#555555" if self.mouse_over else "#444444")), + color=Color.parse("bright_yellow" if self.grabbed else "bright_magenta"), + ) return ScrollBarRender( virtual_size=self.virtual_size, window_size=self.window_size, position=self.position, vertical=self.vertical, - style="bright_magenta on #555555" - if self.mouse_over - else "bright_magenta on #444444", + style=style, ) async def on_enter(self, event: events.Enter) -> None: @@ -189,6 +210,28 @@ class ScrollBar(Widget): async def action_scroll_up(self) -> None: await self.emit(ScrollUp(self)) + async def action_grab(self) -> None: + await self.capture_mouse() + + async def action_released(self) -> None: + await self.capture_mouse(False) + + async def on_mouse_up(self, event: events.MouseUp) -> None: + if self.grabbed: + await self.release_mouse() + await super().on_mouse_up(event) + + async def on_mouse_captured(self, event: events.MouseCaptured) -> None: + self.grabbed = True + + async def on_mouse_released(self, event: events.MouseReleased) -> None: + self.grabbed = False + + async def on_mouse_move(self, event: events.MouseMove) -> None: + if self.grabbed: + delta_y = event.delta_y * (self.virtual_size / self.size.height) + await self.emit(ScrollRelative(self, y=delta_y)) + if __name__ == "__main__": from rich.console import Console diff --git a/src/textual/view.py b/src/textual/view.py index ebb9f0328..9f9f6396f 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -121,9 +121,16 @@ class View(Widget): def get_style_at(self, x: int, y: int) -> Style: return self.layout.get_style_at(x, y) + def get_widget_region(self, widget: Widget) -> Region: + return self.layout.get_widget_region(widget) + async def _on_mouse_move(self, event: events.MouseMove) -> None: try: - widget, region = self.get_widget_at(event.x, event.y) + if self.app.mouse_captured: + widget = self.app.mouse_captured + region = self.get_widget_region(widget) + else: + widget, region = self.get_widget_at(event.x, event.y) except NoWidget: await self.app.set_mouse_over(None) else: @@ -140,6 +147,9 @@ class View(Widget): event.shift, event.meta, event.ctrl, + screen_x=event.screen_x, + screen_y=event.screen_y, + style=event.style, ) ) @@ -149,17 +159,22 @@ class View(Widget): await self.post_message(event) elif isinstance(event, events.MouseMove): + event.style = self.get_style_at(event.screen_x, event.screen_y) await self._on_mouse_move(event) elif isinstance(event, events.MouseEvent): - try: - widget, region = self.get_widget_at(event.x, event.y) + if self.app.mouse_captured: + widget = self.app.mouse_captured + region = self.get_widget_region(widget) + else: + widget, region = self.get_widget_at(event.x, event.y) except NoWidget: pass else: if isinstance(event, events.MouseDown) and widget.can_focus: await self.app.set_focus(widget) + event.style = self.get_style_at(event.screen_x, event.screen_y) await widget.forward_event(event.offset(-region.x, -region.y)) elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)): @@ -199,7 +214,7 @@ class DockView(View): z: int = 0, size: int | None | DoNotSet = do_not_set, name: str | None = None - ) -> Widget | tuple[Widget, ...]: + ) -> None: dock = Dock(edge, widgets, z) assert isinstance(self.layout, DockLayout) @@ -208,11 +223,8 @@ class DockView(View): if size is not do_not_set: widget.layout_size = cast(Optional[int], size) if not self.is_mounted(widget): - await self.mount(widget) + if name is None: + await self.mount(widget) + else: + await self.mount(**{name: widget}) await self.refresh_layout() - - widget, *rest = widgets - if rest: - return widgets - else: - return widget diff --git a/src/textual/widget.py b/src/textual/widget.py index e43372e6a..e377b0273 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import partial from logging import getLogger from typing import ( Callable, @@ -143,6 +144,9 @@ class Widget(MessagePump): offset_x, offset_y = self.root_view.get_offset(self) return self.root_view.get_style_at(x + offset_x, y + offset_y) + async def call_later(self, callback: Callable, *args, **kwargs) -> None: + await self.app.call_later(callback, *args, **kwargs) + async def forward_event(self, event: events.Event) -> None: await self.post_message(event) @@ -200,12 +204,15 @@ class Widget(MessagePump): async def capture_mouse(self, capture: bool = True) -> None: await self.app.capture_mouse(self if capture else None) - async def on_mouse_move(self, event: events.MouseMove) -> None: - style_under_cursor = self.get_style_at(event.x, event.y) - log.debug("%r", style_under_cursor) + async def release_mouse(self) -> None: + await self.app.capture_mouse(None) + + 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 + ) async def on_mouse_up(self, event: events.MouseUp) -> None: - style = self.get_style_at(event.x, event.y) - if "@click" in style.meta: - log.debug(style._link_id) - await self.app.action(style.meta["@click"], default_namespace=self) + if "@click" in event.style.meta: + await self.app.action(event.style.meta["@click"], default_namespace=self) diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index b89f68c7c..f754cf972 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -8,7 +8,7 @@ from rich.style import StyleType from .. import events from ..message import Message -from ..scrollbar import ScrollBar, ScrollDown, ScrollUp +from ..scrollbar import ScrollBar, ScrollDown, ScrollUp, ScrollRelative from ..geometry import clamp from ..page import Page from ..view import DockView @@ -21,7 +21,7 @@ log = logging.getLogger("rich") class ScrollView(DockView): def __init__( self, - renderable: RenderableType, + renderable: RenderableType | None = None, *, name: str | None = None, style: StyleType = "", @@ -29,7 +29,7 @@ class ScrollView(DockView): ) -> None: self.fluid = fluid self._vertical_scrollbar = ScrollBar(vertical=True) - self._page = Page(renderable, style=style) + self._page = Page(renderable or "", style=style) super().__init__(name="ScrollView") x: Reactive[float] = Reactive(0) @@ -47,6 +47,10 @@ class ScrollView(DockView): self._page.y = round(new_value) self._vertical_scrollbar.position = round(new_value) + async def update(self, renderabe: RenderableType) -> None: + self._page.update(renderabe) + self.require_repaint() + async def on_mount(self, event: events.Mount) -> None: await self.dock(self._vertical_scrollbar, edge="right", size=1) await self.dock(self._page, edge="top") @@ -105,10 +109,15 @@ class ScrollView(DockView): self._page.update() await super().on_resize(event) - async def on_message(self, message: Message) -> None: - if isinstance(message, ScrollUp): - self.page_up() - elif isinstance(message, ScrollDown): - self.page_down() - else: - await super().on_message(message) + async def message_scroll_up(self, message: Message) -> None: + self.page_up() + + async def message_scroll_down(self, message: Message) -> None: + self.page_down() + + async def message_scroll_relative(self, message: ScrollRelative) -> None: + if message.x is not None: + 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")