diff --git a/pyproject.toml b/pyproject.toml index 65885fa15..e948fb9c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "textual" version = "0.1.0" -description = "Rich TUI" +description = "Text User Interface using Rich" authors = ["Will McGugan "] license = "MIT" diff --git a/rich-tui.code-workspace b/rich-tui.code-workspace index 96b94915e..a5b381852 100644 --- a/rich-tui.code-workspace +++ b/rich-tui.code-workspace @@ -2,6 +2,9 @@ "folders": [ { "path": "." + }, + { + "path": "../rich" } ], "settings": { diff --git a/src/textual/_line_cache.py b/src/textual/_line_cache.py new file mode 100644 index 000000000..181d72d4a --- /dev/null +++ b/src/textual/_line_cache.py @@ -0,0 +1,39 @@ +from typing import Iterable, List + +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.control import Control +from rich.segment import Segment + + +class LineCache: + def __init__(self, lines: List[List[Segment]]) -> None: + self.lines = lines + self.dirty = [True] * len(self.lines) + + @classmethod + def from_renderable( + cls, + console: Console, + renderable: RenderableType, + width: int, + height: int, + ) -> "LineCache": + options = console.options.update_dimensions(width, height) + lines = console.render_lines(renderable, options, new_lines=False) + return cls(lines) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + new_line = Segment.line() + for line in self.lines: + yield from line + yield new_line + + def render(self, x: int, y: int) -> Iterable[Segment]: + move_to = Control.move_to + for offset_y, (line, dirty) in enumerate(zip(self.lines, self.dirty), y): + if dirty: + yield move_to(x, offset_y).segment + yield from line + self.dirty[:] = [False] * len(self.lines) diff --git a/src/textual/app.py b/src/textual/app.py index 0eed9028b..48d81c227 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -90,6 +90,9 @@ class App(MessagePump): Screen(Control.home(), self.view, Control.home(), application_mode=True) ) + async def on_idle(self, event: events.Idle) -> None: + await self.view.post_message(event) + async def action(self, action: str) -> None: if "." in action: destination, action_name, *tokens = action.split(".") diff --git a/src/textual/events.py b/src/textual/events.py index fc4c04dce..616602387 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -220,7 +220,6 @@ class Enter(Event, type=EventType.ENTER): yield "y", self.y -@rich_repr class Leave(Event, type=EventType.LEAVE): pass diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 4c6b2f067..2f45e2140 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -121,9 +121,11 @@ class MessagePump: except Exception: log.exception("error getting message") break - await self.dispatch_message(message, priority) - if self._message_queue.empty(): - await self.dispatch_message(events.Idle(self)) + try: + await self.dispatch_message(message, priority) + finally: + if self._message_queue.empty(): + await self.dispatch_message(events.Idle(self)) async def dispatch_message( self, message: Message, priority: int = 0 diff --git a/src/textual/view.py b/src/textual/view.py index 9bafdcc63..19dd7fe18 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -81,6 +81,7 @@ class LayoutView(View): self.focused: Optional[MessagePump] = None self._widgets: Set[Widget] = set() super().__init__() + self.enable_messages(events.Idle) def __rich_repr__(self) -> RichReprResult: yield "name", self.name @@ -136,9 +137,6 @@ class LayoutView(View): ) self.app.refresh() - async def on_idle(self, event: events.Idle) -> None: - pass - async def on_move(self, event: events.Move) -> None: try: widget, region = self.get_widget_at(event.x, event.y) diff --git a/src/textual/widget.py b/src/textual/widget.py index 8f14b6a0c..45d6500be 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2,6 +2,7 @@ from logging import getLogger from typing import ( ClassVar, Generic, + Iterable, List, NamedTuple, Optional, @@ -20,6 +21,7 @@ from rich.segment import Segment from . import events from ._context import active_app +from ._line_cache import LineCache from .message import Message from .message_pump import MessagePump @@ -44,6 +46,7 @@ class Reactive(Generic[T]): def __set__(self, obj: "Widget", value: T) -> None: if getattr(obj, self.internal_name) != value: + log.debug("%s -> %s", self.internal_name, value) setattr(obj, self.internal_name, value) obj.require_refresh() @@ -66,8 +69,7 @@ class Widget(MessagePump): self.size = WidgetDimensions(0, 0) self.size_changed = False self._refresh_required = False - self._dirty_lines: List[bool] = [] - self._line_cache: List[List[Segment]] = [] + self._line_cache: Optional[LineCache] = None super().__init__() if not self.mouse_events: self.disable_messages( @@ -94,6 +96,9 @@ class Widget(MessagePump): has_focus: Reactive[bool] = Reactive(False) mouse_over: Reactive[bool] = Reactive(False) + def __rich_repr__(self) -> RichReprResult: + yield "name", self.name + @property def app(self) -> "App": """Get the current app.""" @@ -104,25 +109,26 @@ class Widget(MessagePump): """Get the current console.""" return active_app.get().console + @property + def line_cache(self) -> LineCache: + + if self._line_cache is None: + width, height = self.size + renderable = self.render() + self._line_cache = LineCache.from_renderable( + self.console, renderable, width, height + ) + assert self._line_cache is not None + return self._line_cache + + def __rich__(self) -> LineCache: + return self.line_cache + def require_refresh(self) -> None: - self._dirty_lines[:] = [True] * len(self._line_cache) - self.app.refresh() + self._line_cache = None - async def refresh(self) -> None: - self.app.refresh() - - def __rich_repr__(self) -> RichReprResult: - yield "name", self.name - - def render_line_cache(self) -> None: - console = self.console - options = console.options.update_dimensions(self.size.width, self.size.height) - renderable = self.render() - self._line_cache[:] = console.render_lines(renderable, options, new_lines=False) - self._dirty_lines = [True] * len(self._line_cache) - - def _clean_line_cache(self) -> None: - self._dirty_lines = [False] * len(self._line_cache) + def render_update(self, x: int, y: int) -> Iterable[Segment]: + yield from self.line_cache.render(x, y) def render(self) -> RenderableType: return Panel( @@ -132,17 +138,6 @@ class Widget(MessagePump): box=box.HEAVY if self.has_focus else box.ROUNDED, ) - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - self.render_line_cache() - new_line = Segment.line() - for line in self._line_cache: - yield from line - yield new_line - self._clean_line_cache() - self._refresh_required = True - async def post_message( self, message: Message, priority: Optional[int] = None ) -> bool: @@ -167,3 +162,6 @@ class Widget(MessagePump): async def on_blur(self, event: events.Focus) -> None: self.has_focus = False + + async def on_idle(self, event: events.Idle) -> None: + self.app.refresh()