From c3d0020fed9c2e99982330809fa85d1f9967c196 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 13 Jun 2021 12:58:25 +0100 Subject: [PATCH] scrolling --- src/textual/_linux_driver.py | 1 - src/textual/_parser.py | 3 +- src/textual/_xterm_parser.py | 6 +- src/textual/app.py | 12 +- src/textual/driver.py | 270 +++++++++++++++++----------------- src/textual/events.py | 14 +- src/textual/message_pump.py | 2 + src/textual/scrollbar.py | 40 +++-- src/textual/view.py | 13 +- src/textual/widget.py | 8 +- src/textual/widgets/window.py | 13 ++ 11 files changed, 204 insertions(+), 178 deletions(-) diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index 9ede1fc68..aa3ff4d75 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -191,7 +191,6 @@ class LinuxDriver(Driver): selector_events = selector.select(0.1) for _selector_key, mask in selector_events: unicode_data = decode(read(fileno, 1024)) - log.debug(repr(unicode_data)) for event in parser.feed(unicode_data): send_event(event) diff --git a/src/textual/_parser.py b/src/textual/_parser.py index 05126465a..26637c602 100644 --- a/src/textual/_parser.py +++ b/src/textual/_parser.py @@ -97,11 +97,12 @@ class Parser(Generic[T]): pos = 0 tokens = self._tokens popleft = tokens.popleft + data_size = len(data) while tokens: yield popleft() - while pos < len(data) or isinstance(self._awaiting, PeekBuffer): + while pos < data_size or isinstance(self._awaiting, PeekBuffer): _awaiting = self._awaiting if isinstance(_awaiting, _Read1): diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 36b25f740..6ebe6d7d9 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -35,9 +35,9 @@ class XTermParser(Parser[events.Event]): event_class: Type[events._MouseBase] if buttons & 32: - event_class = events.Move + event_class = events.MouseMove else: - event_class = events.Press if state == "M" else events.Release + event_class = events.MouseDown if state == "M" else events.MouseUp button = (4 if (buttons & 64) else 1) + (buttons & 3) event = event_class( sender, @@ -60,7 +60,7 @@ class XTermParser(Parser[events.Event]): while not self.is_eof: character = yield read1() - if character == ESC and ((yield self.peek_buffer())): + if character == ESC and ((yield self.peek_buffer()) or more_data()): sequence: str = character while True: sequence += yield read1() diff --git a/src/textual/app.py b/src/textual/app.py index e42e13759..b12753cc6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -126,6 +126,8 @@ class App(MessagePump): if key_action is not None: log.debug("action %r", key_action) await self.action(key_action) + else: + await self.view.post_message(event) # if event.key == "q": # await self.close_messages() @@ -137,10 +139,13 @@ class App(MessagePump): async def on_resize(self, event: events.Resize) -> None: await self.view.post_message(event) - async def on_move(self, event: events.Move) -> None: + async def on_mouse_move(self, event: events.MouseMove) -> None: await self.view.post_message(event) - async def on_click(self, event: events.Click) -> None: + async def on_mouse_down(self, event: events.MouseDown) -> None: + await self.view.post_message(event) + + async def on_mouse_up(self, event: events.MouseUp) -> None: await self.view.post_message(event) async def action_quit(self, tokens: list[str]) -> None: @@ -165,7 +170,7 @@ if __name__ == "__main__": ) with open("richreadme.md", "rt") as fh: - readme = Markdown(fh.read(), hyperlinks=True) + readme = Markdown(fh.read(), hyperlinks=True, code_theme="fruity") from rich import print @@ -177,6 +182,5 @@ if __name__ == "__main__": await self.view.mount_all( header=Header(self.title), left=Placeholder(), body=Window(readme) ) - # self.set_timer(3.0, callback=self.close_messages) MyApp.run() diff --git a/src/textual/driver.py b/src/textual/driver.py index 3729f2f4a..1bdd253c6 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -38,163 +38,155 @@ class Driver(ABC): ... -class LinuxDriver(Driver): - def start_application_mode(self): - pass +# class CursesDriver(Driver): - def stop_application_mode(self): - pass +# _MOUSE_PRESSED = [ +# curses.BUTTON1_PRESSED, +# curses.BUTTON2_PRESSED, +# curses.BUTTON3_PRESSED, +# curses.BUTTON4_PRESSED, +# ] +# _MOUSE_RELEASED = [ +# curses.BUTTON1_RELEASED, +# curses.BUTTON2_RELEASED, +# curses.BUTTON3_RELEASED, +# curses.BUTTON4_RELEASED, +# ] -class CursesDriver(Driver): +# _MOUSE_CLICKED = [ +# curses.BUTTON1_CLICKED, +# curses.BUTTON2_CLICKED, +# curses.BUTTON3_CLICKED, +# curses.BUTTON4_CLICKED, +# ] - _MOUSE_PRESSED = [ - curses.BUTTON1_PRESSED, - curses.BUTTON2_PRESSED, - curses.BUTTON3_PRESSED, - curses.BUTTON4_PRESSED, - ] +# _MOUSE_DOUBLE_CLICKED = [ +# curses.BUTTON1_DOUBLE_CLICKED, +# curses.BUTTON2_DOUBLE_CLICKED, +# curses.BUTTON3_DOUBLE_CLICKED, +# curses.BUTTON4_DOUBLE_CLICKED, +# ] - _MOUSE_RELEASED = [ - curses.BUTTON1_RELEASED, - curses.BUTTON2_RELEASED, - curses.BUTTON3_RELEASED, - curses.BUTTON4_RELEASED, - ] +# _MOUSE = [ +# (events.MouseDown, _MOUSE_PRESSED), +# (events.MouseUp, _MOUSE_RELEASED), +# (events.Click, _MOUSE_CLICKED), +# (events.DoubleClick, _MOUSE_DOUBLE_CLICKED), +# ] - _MOUSE_CLICKED = [ - curses.BUTTON1_CLICKED, - curses.BUTTON2_CLICKED, - curses.BUTTON3_CLICKED, - curses.BUTTON4_CLICKED, - ] +# def __init__(self, console: "Console", target: "MessageTarget") -> None: +# super().__init__(console, target) +# self._stdscr = None +# self._exit_event = Event() +# self._key_thread: Thread | None = None - _MOUSE_DOUBLE_CLICKED = [ - curses.BUTTON1_DOUBLE_CLICKED, - curses.BUTTON2_DOUBLE_CLICKED, - curses.BUTTON3_DOUBLE_CLICKED, - curses.BUTTON4_DOUBLE_CLICKED, - ] +# def _get_terminal_size(self) -> tuple[int, int]: +# width: int | None = 80 +# height: int | None = 25 +# if WINDOWS: # pragma: no cover +# width, height = shutil.get_terminal_size() +# else: +# try: +# width, height = os.get_terminal_size(sys.stdin.fileno()) +# except (AttributeError, ValueError, OSError): +# try: +# width, height = os.get_terminal_size(sys.stdout.fileno()) +# except (AttributeError, ValueError, OSError): +# pass +# width = width or 80 +# height = height or 25 +# return width, height - _MOUSE = [ - (events.Press, _MOUSE_PRESSED), - (events.Release, _MOUSE_RELEASED), - (events.Click, _MOUSE_CLICKED), - (events.DoubleClick, _MOUSE_DOUBLE_CLICKED), - ] +# def start_application_mode(self): +# loop = asyncio.get_event_loop() - def __init__(self, console: "Console", target: "MessageTarget") -> None: - super().__init__(console, target) - self._stdscr = None - self._exit_event = Event() - self._key_thread: Thread | None = None +# def on_terminal_resize(signum, stack) -> None: +# terminal_size = self._get_terminal_size() +# width, height = terminal_size +# event = events.Resize(self._target, width, height) +# self.console.size = terminal_size +# asyncio.run_coroutine_threadsafe( +# self._target.post_message(event), +# loop=loop, +# ) - def _get_terminal_size(self) -> tuple[int, int]: - width: int | None = 80 - height: int | None = 25 - if WINDOWS: # pragma: no cover - width, height = shutil.get_terminal_size() - else: - try: - width, height = os.get_terminal_size(sys.stdin.fileno()) - except (AttributeError, ValueError, OSError): - try: - width, height = os.get_terminal_size(sys.stdout.fileno()) - except (AttributeError, ValueError, OSError): - pass - width = width or 80 - height = height or 25 - return width, height +# signal.signal(signal.SIGWINCH, on_terminal_resize) +# self._stdscr = curses.initscr() +# curses.noecho() +# curses.cbreak() +# curses.halfdelay(1) +# curses.mousemask(curses.REPORT_MOUSE_POSITION | curses.ALL_MOUSE_EVENTS) +# # curses.mousemask(-1) - def start_application_mode(self): - loop = asyncio.get_event_loop() +# self._stdscr.keypad(True) +# self.console.show_cursor(False) +# self.console.file.write("\033[?1003h\n") +# self._key_thread = Thread( +# target=self.run_key_thread, args=(asyncio.get_event_loop(),) +# ) - def on_terminal_resize(signum, stack) -> None: - terminal_size = self._get_terminal_size() - width, height = terminal_size - event = events.Resize(self._target, width, height) - self.console.size = terminal_size - asyncio.run_coroutine_threadsafe( - self._target.post_message(event), - loop=loop, - ) +# width, height = self.console.size = self._get_terminal_size() +# asyncio.run_coroutine_threadsafe( +# self._target.post_message(events.Resize(self._target, width, height)), +# loop=loop, +# ) - signal.signal(signal.SIGWINCH, on_terminal_resize) - self._stdscr = curses.initscr() - curses.noecho() - curses.cbreak() - curses.halfdelay(1) - curses.mousemask(curses.REPORT_MOUSE_POSITION | curses.ALL_MOUSE_EVENTS) - # curses.mousemask(-1) +# self._key_thread.start() - self._stdscr.keypad(True) - self.console.show_cursor(False) - self.console.file.write("\033[?1003h\n") - self._key_thread = Thread( - target=self.run_key_thread, args=(asyncio.get_event_loop(),) - ) +# def stop_application_mode(self): - width, height = self.console.size = self._get_terminal_size() - asyncio.run_coroutine_threadsafe( - self._target.post_message(events.Resize(self._target, width, height)), - loop=loop, - ) +# signal.signal(signal.SIGWINCH, signal.SIG_DFL) - self._key_thread.start() +# self._exit_event.set() +# self._key_thread.join() +# curses.nocbreak() +# self._stdscr.keypad(False) +# curses.echo() +# curses.endwin() +# self.console.show_cursor(True) - def stop_application_mode(self): +# def run_key_thread(self, loop) -> None: +# stdscr = self._stdscr +# assert stdscr is not None +# exit_event = self._exit_event - signal.signal(signal.SIGWINCH, signal.SIG_DFL) +# def send_event(event: events.Event) -> None: +# asyncio.run_coroutine_threadsafe( +# self._target.post_message(event), +# loop=loop, +# ) - self._exit_event.set() - self._key_thread.join() - curses.nocbreak() - self._stdscr.keypad(False) - curses.echo() - curses.endwin() - self.console.show_cursor(True) +# while not exit_event.is_set(): +# code = stdscr.getch() +# if code == -1: +# continue - def run_key_thread(self, loop) -> None: - stdscr = self._stdscr - assert stdscr is not None - exit_event = self._exit_event +# if code == curses.KEY_MOUSE: - def send_event(event: events.Event) -> None: - asyncio.run_coroutine_threadsafe( - self._target.post_message(event), - loop=loop, - ) - - while not exit_event.is_set(): - code = stdscr.getch() - if code == -1: - continue - - if code == curses.KEY_MOUSE: - - try: - _id, x, y, _z, button_state = curses.getmouse() - except Exception: - log.exception("error in curses.getmouse") - else: - if button_state & curses.REPORT_MOUSE_POSITION: - send_event(events.Move(self._target, x, y)) - alt = bool(button_state & curses.BUTTON_ALT) - ctrl = bool(button_state & curses.BUTTON_CTRL) - shift = bool(button_state & curses.BUTTON_SHIFT) - for event_type, masks in self._MOUSE: - for button, mask in enumerate(masks, 1): - if button_state & mask: - send_event( - event_type( - self._target, - x, - y, - button, - alt=alt, - ctrl=ctrl, - shift=shift, - ) - ) - else: - send_event(events.Key(self._target, code=code)) +# try: +# _id, x, y, _z, button_state = curses.getmouse() +# except Exception: +# log.exception("error in curses.getmouse") +# else: +# if button_state & curses.REPORT_MOUSE_POSITION: +# send_event(events.MouseMove(self._target, x, y)) +# alt = bool(button_state & curses.BUTTON_ALT) +# ctrl = bool(button_state & curses.BUTTON_CTRL) +# shift = bool(button_state & curses.BUTTON_SHIFT) +# for event_type, masks in self._MOUSE: +# for button, mask in enumerate(masks, 1): +# if button_state & mask: +# send_event( +# event_type( +# self._target, +# x, +# y, +# button, +# alt=alt, +# ctrl=ctrl, +# shift=shift, +# ) +# ) +# else: +# send_event(events.Key(self._target, code=code)) diff --git a/src/textual/events.py b/src/textual/events.py index 20947d03b..9f6663364 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -36,9 +36,9 @@ class EventType(Enum): FOCUS = auto() BLUR = auto() KEY = auto() - MOVE = auto() - PRESS = auto() - RELEASE = auto() + MOUSE_MOVE = auto() + MOUSE_DOWN = auto() + MOUSE_UP = auto() CLICK = auto() DOUBLE_CLICK = auto() ENTER = auto() @@ -134,7 +134,7 @@ class Key(Event, type=EventType.KEY, bubble=True): @rich_repr -class _MouseBase(Event, type=EventType.PRESS): +class _MouseBase(Event, type=EventType.MOUSE_MOVE): __slots__ = ["x", "y", "button"] def __init__( @@ -164,15 +164,15 @@ class _MouseBase(Event, type=EventType.PRESS): yield "ctrl", self.ctrl, False -class Move(_MouseBase, type=EventType.MOVE): +class MouseMove(_MouseBase, type=EventType.MOUSE_MOVE): pass -class Press(_MouseBase, type=EventType.MOVE): +class MouseDown(_MouseBase, type=EventType.MOUSE_DOWN): pass -class Release(_MouseBase, type=EventType.RELEASE): +class MouseUp(_MouseBase, type=EventType.MOUSE_UP): pass diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index f9b70bc72..53199bedf 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -157,6 +157,8 @@ class MessagePump: try: await self.dispatch_message(message, priority) + except Exception: + log.exception("error dispatching %r", message) finally: if self._message_queue.empty(): idle_handler = getattr(self, "on_idle", None) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 6b72e5976..791869ef8 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -1,14 +1,14 @@ from __future__ import annotations +from math import ceil +import logging from typing import Iterable from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.segment import Segment from rich.style import Style - -# def add_vertical_bar(lines:list[list[Segment]], size:float, window_size:float, position:float) -> None -# bar = render_bar(len(lines), size, window_size, po) +log = logging.getLogger("rich") class VerticalBar: @@ -88,25 +88,33 @@ def render_bar( step_size = virtual_size / size start = position / step_size - end = (position + window_size) / step_size + # end = (position + window_size) / step_size + end = start + window_size / step_size start_index = int(start) - end_index = int(end) - bar_height = (end_index - start_index) + 1 + end_index = start_index + ceil(window_size / step_size) + bar_height = end_index - start_index segments[start_index:end_index] = [bar_segment] * bar_height - sub_position = start % 1.0 - if sub_position >= 0.5: - segments[start_index] = start_bar_segment - elif start_index: - segments[start_index - 1] = end_back_segment + # log.debug("f") + # sub_position = 1 - (start % 1.0) + # log.debug("*** sub_position=%r, %r", start, sub_position) - sub_position = end % 1.0 - if sub_position < 0.5: - segments[end_index] = end_bar_segment - elif end_index + 1 < len(segments): - segments[end_index + 1] = start_back_segment + # if sub_position > 0.5: + # segments[start_index - 1] = end_back_segment + # segments[start_index] = start_back_segment + # else: + # segments[start_index] = start_bar_segment + # # segments[start_index + 1] = start_bar_segment + + # sub_position = end % 1.0 + # if sub_position > 0.5: + # segments[end_index] = end_bar_segment + # segments[end_index + 1] = back_segment + # else: + # segments[end_index] = start_back_segment + # segments[end_index + 1] = start_back_segment return segments diff --git a/src/textual/view.py b/src/textual/view.py index 9ec83495d..c4d27ffce 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -127,8 +127,10 @@ class LayoutView(View): self._widgets.add(widget) async def set_focus(self, widget: Optional[Widget]) -> None: + log.debug("set_focus %r", widget) if widget == self.focused: return + if widget is None: if self.focused is not None: focused = self.focused @@ -153,7 +155,7 @@ class LayoutView(View): ) self.app.refresh() - async def on_move(self, event: events.Move) -> None: + async def on_mouse_move(self, event: events.MouseMove) -> None: try: widget, region = self.get_widget_at(event.x, event.y) except NoWidget: @@ -174,7 +176,7 @@ class LayoutView(View): finally: self.mouse_over = widget await widget.post_message( - events.Move( + events.MouseMove( self, event.x - region.x, event.y - region.y, @@ -185,10 +187,15 @@ class LayoutView(View): ) ) - async def on_click(self, event: events.Click) -> None: + async def on_mouse_down(self, event: events.Click) -> None: try: widget, _region = self.get_widget_at(event.x, event.y) except NoWidget: await self.set_focus(None) else: await self.set_focus(widget) + + async def on_key(self, event: events.Key) -> None: + log.debug("view.on_key; %s, %r", event, self.focused) + if self.focused: + await self.focused.post_message(event) diff --git a/src/textual/widget.py b/src/textual/widget.py index 945f3627b..0dc5dca68 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -71,16 +71,16 @@ class Widget(MessagePump): super().__init__() if not self.mouse_events: self.disable_messages( - events.Move, - events.Press, - events.Release, + events.MouseMove, + events.MouseDown, + events.MouseUp, events.Click, events.DoubleClick, ) def __init_subclass__( cls, - can_focus: bool = False, + can_focus: bool = True, mouse_events: bool = True, ) -> None: super().__init_subclass__() diff --git a/src/textual/widgets/window.py b/src/textual/widgets/window.py index 0ed0d1482..3df616a1f 100644 --- a/src/textual/widgets/window.py +++ b/src/textual/widgets/window.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging +import logging import sys if sys.version_info >= (3, 8): @@ -10,10 +12,13 @@ else: from rich.console import Console, ConsoleOptions, RenderableType from rich.segment import Segment +from .. import events from ..widget import Widget, Reactive from ..geometry import Point, Dimensions from ..scrollbar import VerticalBar +log = logging.getLogger("rich") + ScrollMethod = Literal["always", "never", "auto", "overlay"] @@ -66,3 +71,11 @@ class Window(Widget): self.position, overlay=self.y_scroll == "overlay", ) + + async def on_key(self, event: events.Key) -> None: + log.debug("window.on_key; %s", event) + if event.key == "down": + self.position += 1 + elif event.key == "up": + self.position -= 1 + event.stop_propagation()