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 rich.markdown import Markdown
from textual import events from textual import events
@@ -6,6 +9,14 @@ from textual.view import DockView
from textual.widgets import Header, Footer, Placeholder, ScrollView from textual.widgets import Header, Footer, Placeholder, ScrollView
logging.basicConfig(
level="NOTSET",
format="%(message)s",
datefmt="[%X]",
handlers=[FileHandler("richtui.log")],
)
class MyApp(App): class MyApp(App):
"""An example of a very simple Textual App""" """An example of a very simple Textual App"""
@@ -14,25 +25,27 @@ class MyApp(App):
await self.bind("b", "view.toggle('sidebar')") await self.bind("b", "view.toggle('sidebar')")
async def on_startup(self, event: events.Startup) -> None: async def on_startup(self, event: events.Startup) -> None:
view = await self.push_view(DockView()) view = await self.push_view(DockView())
header = Header(self.title)
footer = Footer() 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("b", "Toggle sidebar")
footer.add_key("q", "Quit") footer.add_key("q", "Quit")
header = Header(self.title)
body = ScrollView()
sidebar = Placeholder()
await view.dock(header, edge="top") await view.dock(header, edge="top")
await view.dock(footer, edge="bottom") 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") 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") MyApp.run(title="Simple App")
app.run()

View File

@@ -120,12 +120,15 @@ class Animator:
def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None: def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None:
self._animations: dict[tuple[object, str], Animation] = {} self._animations: dict[tuple[object, str], Animation] = {}
self._timer = Timer(target, 1 / frames_per_second, target, callback=self) self._timer = Timer(target, 1 / frames_per_second, target, callback=self)
self._timer_task: asyncio.Task | None = None
async def start(self) -> 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: async def stop(self) -> None:
self._timer.stop() self._timer.stop()
if self._timer_task:
await self._timer_task
def bind(self, obj: object) -> BoundAnimator: def bind(self, obj: object) -> BoundAnimator:
return BoundAnimator(self, obj) return BoundAnimator(self, obj)

View File

@@ -50,8 +50,10 @@ class LinuxDriver(Driver):
def _enable_mouse_support(self) -> None: def _enable_mouse_support(self) -> None:
write = self.console.file.write write = self.console.file.write
write("\x1b[?1000h") write("\x1b[?1000h")
write("\x1b[?1015h") write("\x1b[?1015h")
write("\x1b[?1006h") write("\x1b[?1006h")
# write("\x1b[?1007h") # write("\x1b[?1007h")
self.console.file.flush() self.console.file.flush()

View File

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

View File

@@ -2,10 +2,10 @@ from __future__ import annotations
import os import os
import asyncio import asyncio
from functools import partial
import logging import logging
import signal import signal
from typing import Any, ClassVar, Type, TypeVar from typing import Any, Callable, ClassVar, Type, TypeVar
import warnings import warnings
from rich.control import Control from rich.control import Control
@@ -78,7 +78,7 @@ class App(MessagePump):
self.driver_class = driver_class or LinuxDriver self.driver_class = driver_class or LinuxDriver
self.title = title self.title = title
self._layout = DockLayout() self._layout = DockLayout()
self._view_stack: list[View] = [View()] self._view_stack: list[View] = []
self.children: set[MessagePump] = set() self.children: set[MessagePump] = set()
self.focused: Widget | None = None self.focused: Widget | None = None
@@ -117,7 +117,11 @@ class App(MessagePump):
@classmethod @classmethod
def run( 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. """Run the app.
@@ -128,8 +132,7 @@ class App(MessagePump):
""" """
async def run_app() -> None: 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() await app.process_messages()
asyncio.run(run_app()) asyncio.run(run_app())
@@ -137,7 +140,7 @@ class App(MessagePump):
async def push_view(self, view: ViewType) -> ViewType: async def push_view(self, view: ViewType) -> ViewType:
await self.register(view) await self.register(view)
view.set_parent(self) view.set_parent(self)
self._view_stack[0] = view self._view_stack.append(view)
return view return view
def on_keyboard_interupt(self) -> None: def on_keyboard_interupt(self) -> None:
@@ -181,7 +184,13 @@ class App(MessagePump):
async def capture_mouse(self, widget: Widget | None) -> None: async def capture_mouse(self, widget: Widget | None) -> None:
"""Send all Mouse events to a given widget.""" """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 self.mouse_captured = widget
if widget is not None:
await widget.post_message(events.MouseCaptured(self))
async def process_messages(self) -> None: async def process_messages(self) -> None:
log.debug("driver=%r", self.driver_class) log.debug("driver=%r", self.driver_class)
@@ -189,16 +198,18 @@ class App(MessagePump):
# loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt) # loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt)
driver = self._driver = self.driver_class(self.console, self) driver = self._driver = self.driver_class(self.console, self)
active_app.set(self) active_app.set(self)
await self.push_view(View())
self.view.set_parent(self) self.view.set_parent(self)
await self.register(self.view) await self.register(self.view)
if hasattr(self, "on_load"): await self.dispatch_message(events.Load(sender=self))
await self.on_load(events.Load(sender=self))
await self.post_message(events.Startup(sender=self))
try: try:
driver.start_application_mode() driver.start_application_mode()
await self.post_message(events.Startup(sender=self))
self.require_layout()
except Exception: except Exception:
self.console.print_exception() self.console.print_exception()
log.exception("error starting application mode") log.exception("error starting application mode")
@@ -207,11 +218,19 @@ class App(MessagePump):
await self.animator.start() await self.animator.start()
try: try:
await super().process_messages() 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: except Exception:
traceback = Traceback(show_locals=True) traceback = Traceback(show_locals=True)
await self.animator.stop()
await self.view.close_messages()
driver.stop_application_mode() driver.stop_application_mode()
if traceback is not None: if traceback is not None:
self.console.print(traceback) self.console.print(traceback)
@@ -222,6 +241,12 @@ class App(MessagePump):
def require_layout(self) -> None: def require_layout(self) -> None:
self.view.require_layout() 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: async def message_update(self, message: Message) -> None:
self.refresh() self.refresh()
@@ -308,8 +333,8 @@ class App(MessagePump):
if method is not None: if method is not None:
await method(*params) await method(*params)
async def on_load(self, event: events.Load) -> None: async def on_callback(self, event: events.Callback) -> None:
pass await event.callback()
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None: async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
log.debug("shutdown request") log.debug("shutdown request")

View File

@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import Callable, TYPE_CHECKING
from rich.repr import rich_repr, RichReprResult from rich.repr import rich_repr, RichReprResult
from rich.style import Style
from .message import Message from .message import Message
from ._types import MessageTarget from ._types import MessageTarget
@@ -29,6 +30,13 @@ class Null(Event):
return isinstance(message, Null) 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): class ShutdownRequest(Event):
pass pass
@@ -99,6 +107,14 @@ class Hide(Event):
"""Widget has been hidden.""" """Widget has been hidden."""
class MouseCaptured(Event):
"""Mouse has been captured."""
class MouseReleased(Event):
"""Mouse has been released."""
class InputEvent(Event, bubble=True): class InputEvent(Event, bubble=True):
pass pass
@@ -132,6 +148,7 @@ class MouseEvent(InputEvent):
ctrl: bool, ctrl: bool,
screen_x: int | None = None, screen_x: int | None = None,
screen_y: int | None = None, screen_y: int | None = None,
style: Style | None = None,
) -> None: ) -> None:
super().__init__(sender) super().__init__(sender)
self.x = x self.x = x
@@ -144,6 +161,7 @@ class MouseEvent(InputEvent):
self.ctrl = ctrl self.ctrl = ctrl
self.screen_x = x if screen_x is None else screen_x 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.screen_y = y if screen_y is None else screen_y
self._style = style or Style()
def __rich_repr__(self) -> RichReprResult: def __rich_repr__(self) -> RichReprResult:
yield "x", self.x yield "x", self.x
@@ -158,6 +176,15 @@ class MouseEvent(InputEvent):
yield "shift", self.shift, False yield "shift", self.shift, False
yield "meta", self.meta, False yield "meta", self.meta, False
yield "ctrl", self.ctrl, 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): def offset(self, x: int, y: int):
return self.__class__( return self.__class__(
@@ -172,6 +199,7 @@ class MouseEvent(InputEvent):
ctrl=self.ctrl, ctrl=self.ctrl,
screen_x=self.screen_x, screen_x=self.screen_x,
screen_y=self.screen_y, screen_y=self.screen_y,
style=self.style,
) )

View File

@@ -164,6 +164,14 @@ class Layout(ABC):
return segment.style or Style.null() return segment.style or Style.null()
return 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 @property
def cuts(self) -> list[list[int]]: def cuts(self) -> list[list[int]]:
if self._cuts is not None: if self._cuts is not None:

View File

@@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Coroutine, Awaitable, NamedTuple
import asyncio import asyncio
from asyncio import Event, Queue, Task, QueueEmpty
import logging import logging
from asyncio import Event, Queue, QueueEmpty, Task
from typing import Any, Awaitable, Coroutine, NamedTuple
from weakref import WeakSet
from . import events from . import events
from .message import Message
from ._timer import Timer, TimerCallback from ._timer import Timer, TimerCallback
from ._types import MessageHandler from ._types import MessageHandler
from .message import Message
log = logging.getLogger("rich") log = logging.getLogger("rich")
@@ -31,7 +31,7 @@ class MessagePump:
self._disabled_messages: set[type[Message]] = set() self._disabled_messages: set[type[Message]] = set()
self._pending_message: Message | None = None self._pending_message: Message | None = None
self._task: Task | None = None self._task: Task | None = None
self._child_tasks: set[Task] = set() self._child_tasks: WeakSet[Task] = WeakSet()
@property @property
def task(self) -> Task: def task(self) -> Task:
@@ -117,7 +117,7 @@ class MessagePump:
timer = Timer( timer = Timer(
self, interval, self, name=name, callback=callback, repeat=repeat or None 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 return timer
async def close_messages(self, wait: bool = True) -> None: async def close_messages(self, wait: bool = True) -> None:
@@ -126,10 +126,13 @@ class MessagePump:
return return
self._closing = True self._closing = True
await self._message_queue.put(None) await self._message_queue.put(None)
for task in self._child_tasks: for task in self._child_tasks:
task.cancel() task.cancel()
await task
self._child_tasks.clear()
def start_messages(self) -> None: def start_messages(self) -> None:
self._task = asyncio.create_task(self.process_messages()) self._task = asyncio.create_task(self.process_messages())
@@ -167,6 +170,7 @@ class MessagePump:
idle_handler = getattr(self, "on_idle", None) idle_handler = getattr(self, "on_idle", None)
if idle_handler is not None and not self._closed: if idle_handler is not None and not self._closed:
await idle_handler(events.Idle(self)) await idle_handler(events.Idle(self))
log.debug("CLOSED %r", self) log.debug("CLOSED %r", self)
async def dispatch_message(self, message: Message) -> bool | None: async def dispatch_message(self, message: Message) -> bool | None:

View File

@@ -3,9 +3,8 @@ from __future__ import annotations
import logging import logging
from rich.repr import rich_repr, RichReprResult import rich.repr
from rich.color import Color from rich.color import Color
from rich.style import Style
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
from rich.segment import Segment, Segments from rich.segment import Segment, Segments
from rich.style import Style, StyleType from rich.style import Style, StyleType
@@ -13,16 +12,35 @@ from rich.style import Style, StyleType
log = logging.getLogger("rich") log = logging.getLogger("rich")
from . import events from . import events
from ._types import MessageTarget
from .message import Message from .message import Message
from .widget import Reactive, Widget from .widget import Reactive, Widget
@rich.repr.auto
class ScrollUp(Message, bubble=True): class ScrollUp(Message, bubble=True):
pass """Message sent when clicking above handle."""
@rich.repr.auto
class ScrollDown(Message, bubble=True): 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: class ScrollBarRender:
@@ -76,7 +94,7 @@ class ScrollBarRender:
_Style = Style _Style = Style
blank = " " * width_thickness blank = " " * width_thickness
foreground_meta = {"background": False} foreground_meta = {"@click": "release", "@mouse.down": "grab"}
if window_size and size and virtual_size: if window_size and size and virtual_size:
step_size = virtual_size / size step_size = virtual_size / size
@@ -150,7 +168,7 @@ class ScrollBarRender:
yield bar yield bar
@rich_repr @rich.repr.auto
class ScrollBar(Widget): class ScrollBar(Widget):
def __init__(self, vertical: bool = True, name: str | None = None) -> None: def __init__(self, vertical: bool = True, name: str | None = None) -> None:
self.vertical = vertical self.vertical = vertical
@@ -160,21 +178,24 @@ class ScrollBar(Widget):
window_size: Reactive[int] = Reactive(20) window_size: Reactive[int] = Reactive(20)
position: Reactive[int] = Reactive(0) position: Reactive[int] = Reactive(0)
mouse_over: Reactive[bool] = Reactive(False) 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 "virtual_size", self.virtual_size
yield "window_size", self.window_size yield "window_size", self.window_size
yield "position", self.position yield "position", self.position
def render(self) -> RenderableType: 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( return ScrollBarRender(
virtual_size=self.virtual_size, virtual_size=self.virtual_size,
window_size=self.window_size, window_size=self.window_size,
position=self.position, position=self.position,
vertical=self.vertical, vertical=self.vertical,
style="bright_magenta on #555555" style=style,
if self.mouse_over
else "bright_magenta on #444444",
) )
async def on_enter(self, event: events.Enter) -> None: async def on_enter(self, event: events.Enter) -> None:
@@ -189,6 +210,28 @@ class ScrollBar(Widget):
async def action_scroll_up(self) -> None: async def action_scroll_up(self) -> None:
await self.emit(ScrollUp(self)) 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__": if __name__ == "__main__":
from rich.console import Console from rich.console import Console

View File

@@ -121,9 +121,16 @@ class View(Widget):
def get_style_at(self, x: int, y: int) -> Style: def get_style_at(self, x: int, y: int) -> Style:
return self.layout.get_style_at(x, y) 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: async def _on_mouse_move(self, event: events.MouseMove) -> None:
try: 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: except NoWidget:
await self.app.set_mouse_over(None) await self.app.set_mouse_over(None)
else: else:
@@ -140,6 +147,9 @@ class View(Widget):
event.shift, event.shift,
event.meta, event.meta,
event.ctrl, 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) await self.post_message(event)
elif isinstance(event, events.MouseMove): elif isinstance(event, events.MouseMove):
event.style = self.get_style_at(event.screen_x, event.screen_y)
await self._on_mouse_move(event) await self._on_mouse_move(event)
elif isinstance(event, events.MouseEvent): elif isinstance(event, events.MouseEvent):
try: 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: except NoWidget:
pass pass
else: else:
if isinstance(event, events.MouseDown) and widget.can_focus: if isinstance(event, events.MouseDown) and widget.can_focus:
await self.app.set_focus(widget) 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)) await widget.forward_event(event.offset(-region.x, -region.y))
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)): elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
@@ -199,7 +214,7 @@ class DockView(View):
z: int = 0, z: int = 0,
size: int | None | DoNotSet = do_not_set, size: int | None | DoNotSet = do_not_set,
name: str | None = None name: str | None = None
) -> Widget | tuple[Widget, ...]: ) -> None:
dock = Dock(edge, widgets, z) dock = Dock(edge, widgets, z)
assert isinstance(self.layout, DockLayout) assert isinstance(self.layout, DockLayout)
@@ -208,11 +223,8 @@ class DockView(View):
if size is not do_not_set: if size is not do_not_set:
widget.layout_size = cast(Optional[int], size) widget.layout_size = cast(Optional[int], size)
if not self.is_mounted(widget): 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() 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 __future__ import annotations
from functools import partial
from logging import getLogger from logging import getLogger
from typing import ( from typing import (
Callable, Callable,
@@ -143,6 +144,9 @@ class Widget(MessagePump):
offset_x, offset_y = self.root_view.get_offset(self) offset_x, offset_y = self.root_view.get_offset(self)
return self.root_view.get_style_at(x + offset_x, y + offset_y) 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: async def forward_event(self, event: events.Event) -> None:
await self.post_message(event) await self.post_message(event)
@@ -200,12 +204,15 @@ class Widget(MessagePump):
async def capture_mouse(self, capture: bool = True) -> None: async def capture_mouse(self, capture: bool = True) -> None:
await self.app.capture_mouse(self if capture else None) await self.app.capture_mouse(self if capture else None)
async def on_mouse_move(self, event: events.MouseMove) -> None: async def release_mouse(self) -> None:
style_under_cursor = self.get_style_at(event.x, event.y) await self.app.capture_mouse(None)
log.debug("%r", style_under_cursor)
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: async def on_mouse_up(self, event: events.MouseUp) -> None:
style = self.get_style_at(event.x, event.y) if "@click" in event.style.meta:
if "@click" in style.meta: await self.app.action(event.style.meta["@click"], default_namespace=self)
log.debug(style._link_id)
await self.app.action(style.meta["@click"], default_namespace=self)

View File

@@ -8,7 +8,7 @@ from rich.style import StyleType
from .. import events from .. import events
from ..message import Message from ..message import Message
from ..scrollbar import ScrollBar, ScrollDown, ScrollUp from ..scrollbar import ScrollBar, ScrollDown, ScrollUp, ScrollRelative
from ..geometry import clamp from ..geometry import clamp
from ..page import Page from ..page import Page
from ..view import DockView from ..view import DockView
@@ -21,7 +21,7 @@ log = logging.getLogger("rich")
class ScrollView(DockView): class ScrollView(DockView):
def __init__( def __init__(
self, self,
renderable: RenderableType, renderable: RenderableType | None = None,
*, *,
name: str | None = None, name: str | None = None,
style: StyleType = "", style: StyleType = "",
@@ -29,7 +29,7 @@ class ScrollView(DockView):
) -> None: ) -> None:
self.fluid = fluid self.fluid = fluid
self._vertical_scrollbar = ScrollBar(vertical=True) self._vertical_scrollbar = ScrollBar(vertical=True)
self._page = Page(renderable, style=style) self._page = Page(renderable or "", style=style)
super().__init__(name="ScrollView") super().__init__(name="ScrollView")
x: Reactive[float] = Reactive(0) x: Reactive[float] = Reactive(0)
@@ -47,6 +47,10 @@ class ScrollView(DockView):
self._page.y = round(new_value) self._page.y = round(new_value)
self._vertical_scrollbar.position = 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: async def on_mount(self, event: events.Mount) -> None:
await self.dock(self._vertical_scrollbar, edge="right", size=1) await self.dock(self._vertical_scrollbar, edge="right", size=1)
await self.dock(self._page, edge="top") await self.dock(self._page, edge="top")
@@ -105,10 +109,15 @@ class ScrollView(DockView):
self._page.update() self._page.update()
await super().on_resize(event) await super().on_resize(event)
async def on_message(self, message: Message) -> None: async def message_scroll_up(self, message: Message) -> None:
if isinstance(message, ScrollUp): self.page_up()
self.page_up()
elif isinstance(message, ScrollDown): async def message_scroll_down(self, message: Message) -> None:
self.page_down() self.page_down()
else:
await super().on_message(message) 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")