grab to scroll

This commit is contained in:
Will McGugan
2021-07-06 20:16:54 +01:00
parent d306e8864c
commit e0b9dde563
12 changed files with 227 additions and 74 deletions

View File

@@ -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")

View File

@@ -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)

View File

@@ -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()

View File

@@ -81,7 +81,6 @@ class Timer:
count += 1
continue
try:
if await wait_for(_wait(), max(0, next_timer - monotonic())):
break
except TimeoutError:

View File

@@ -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")

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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")