From 4cb9fe1b013027ec0b61ded8dbf5929698ab054b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 17 Jul 2021 16:15:46 +0100 Subject: [PATCH 01/36] fix layout update --- src/textual/app.py | 4 ++++ src/textual/layout.py | 4 ++++ src/textual/view.py | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index a0071187f..97d2696bc 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -365,6 +365,10 @@ class App(MessagePump): else: await super().on_event(event) + async def on_idle(self, event: events.Idle) -> None: + if self.view.check_layout(): + await self.view.refresh_layout() + async def action( self, action: str, diff --git a/src/textual/layout.py b/src/textual/layout.py index 65efad521..e79dedc50 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -94,11 +94,15 @@ class Layout(ABC): def reset(self) -> None: self._cuts = None + if self._require_update: + self.renders.clear() + self._layout_map.clear() def reflow(self, width: int, height: int) -> ReflowResult: self.reset() map = self.generate_map(width, height) + self._require_update = False # Filter out widgets that are off screen or zero area screen_region = Region(0, 0, width, height) diff --git a/src/textual/view.py b/src/textual/view.py index e69ff6b49..65555adc6 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -66,6 +66,9 @@ class View(Widget): def get_offset(self, widget: Widget) -> Point: return self.layout.get_offset(widget) + def check_layout(self) -> bool: + return super().check_layout() or self.layout.check_update() + async def message_update(self, message: UpdateMessage) -> None: widget = message.widget assert isinstance(widget, Widget) @@ -142,7 +145,7 @@ class View(Widget): async def on_idle(self, event: events.Idle) -> None: if self.layout.check_update(): self.layout.reset_update() - self.require_layout() + await self.refresh_layout() async def _on_mouse_move(self, event: events.MouseMove) -> None: try: From f7372529114acc5559c959dcc3ca2ff40b22d5b1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 18 Jul 2021 10:31:00 +0100 Subject: [PATCH 02/36] newline --- examples/animation.py | 23 +++++++--------- examples/calculator.py | 19 ++++++------- examples/grid.py | 19 +++++++------ examples/grid_auto.py | 15 +++++------ examples/simple.py | 19 ++++--------- poetry.lock | 32 ++++++++++++---------- pyproject.toml | 4 +-- src/textual/_input.py | 0 src/textual/app.py | 48 ++++++++++++++++++++------------- src/textual/binding.py | 13 +++++++-- src/textual/view.py | 11 +++++++- src/textual/views/_dock_view.py | 29 +++++++++++++++++++- src/textual/widgets/_footer.py | 21 +++++++++++---- 13 files changed, 154 insertions(+), 99 deletions(-) delete mode 100644 src/textual/_input.py diff --git a/examples/animation.py b/examples/animation.py index b124446dd..2f9119b59 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -1,7 +1,6 @@ from textual import events from textual.app import App from textual.reactive import Reactive -from textual.views import DockView from textual.widgets import Footer, Placeholder @@ -9,33 +8,29 @@ class SmoothApp(App): """Demonstrates smooth animation""" async def on_load(self, event: events.Load) -> None: - await self.bind("q,ctrl+c", "quit") - await self.bind("x", "bang") - await self.bind("b", "toggle_sidebar") + """Bing keys here.""" + await self.bind("b", "toggle_sidebar", "Toggle sidebar") + await self.bind("q", "quit", "Quit") show_bar: Reactive[bool] = Reactive(False) async def watch_show_bar(self, show_bar: bool) -> None: + """Called when show_bar changes.""" self.animator.animate(self.bar, "layout_offset_x", 0 if show_bar else -40) async def action_toggle_sidebar(self) -> None: + """Called when user hits b key.""" self.show_bar = not self.show_bar async def on_startup(self, event: events.Startup) -> None: - - view = await self.push_view(DockView()) - + """Build layout here.""" footer = Footer() self.bar = Placeholder(name="left") self.bar.layout_offset_x = -40 - footer.add_key("b", "Toggle sidebar") - footer.add_key("q", "Quit") - - await view.dock(footer, edge="bottom") - await view.dock(self.bar, edge="left", size=40, z=1) - - await view.dock(Placeholder(), Placeholder(), edge="top") + await self.view.dock(footer, edge="bottom") + await self.view.dock(self.bar, edge="left", size=40, z=1) + await self.view.dock(Placeholder(), Placeholder(), edge="top") SmoothApp.run() diff --git a/examples/calculator.py b/examples/calculator.py index 80ff06d6c..1a8174381 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -108,22 +108,23 @@ class CalculatorApp(App): async def on_startup(self, event: events.Startup) -> None: """Sent when the app has gone full screen.""" - # Create the layout which defines where our widgets will go - layout = GridLayout(gap=(2, 1), gutter=1, align=("center", "center")) - await self.push_view(View(layout=layout)) + # Create a grid layout + grid = await self.view.dock_grid( + gap=(2, 1), gutter=1, align=("center", "center") + ) # Create rows / columns / areas - layout.add_column("col", max_size=30, repeat=4) - layout.add_row("numbers", max_size=15) - layout.add_row("row", max_size=15, repeat=5) - layout.add_areas( + grid.add_column("col", max_size=30, repeat=4) + grid.add_row("numbers", max_size=15) + grid.add_row("row", max_size=15, repeat=5) + grid.add_areas( clear="col1,row1", numbers="col1-start|col4-end,numbers", zero="col1-start|col2-end,row5", ) # Place out widgets in to the layout - layout.place(clear=self.c) - layout.place( + grid.place(clear=self.c) + grid.place( *self.buttons.values(), clear=self.ac, numbers=self.numbers, zero=self.zero ) diff --git a/examples/grid.py b/examples/grid.py index c457f6578..83f30dde3 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -11,25 +11,24 @@ class GridTest(App): async def on_startup(self, event: events.Startup) -> None: - layout = GridLayout() - await self.push_view(View(layout=layout)) + grid = await self.view.dock_grid() - layout.add_column(fraction=1, name="left", min_size=20) - layout.add_column(size=30, name="center") - layout.add_column(fraction=1, name="right") + grid.add_column(fraction=1, name="left", min_size=20) + grid.add_column(size=30, name="center") + grid.add_column(fraction=1, name="right") - layout.add_row(fraction=1, name="top", min_size=2) - layout.add_row(fraction=2, name="middle") - layout.add_row(fraction=1, name="bottom") + grid.add_row(fraction=1, name="top", min_size=2) + grid.add_row(fraction=2, name="middle") + grid.add_row(fraction=1, name="bottom") - layout.add_areas( + grid.add_areas( area1="left,top", area2="center,middle", area3="left-start|right-end,bottom", area4="right,top-start|middle-end", ) - layout.place( + grid.place( area1=Placeholder(name="area1"), area2=Placeholder(name="area2"), area3=Placeholder(name="area3"), diff --git a/examples/grid_auto.py b/examples/grid_auto.py index f7dc3a432..31881f9ef 100644 --- a/examples/grid_auto.py +++ b/examples/grid_auto.py @@ -11,16 +11,15 @@ class GridTest(App): async def on_startup(self, event: events.Startup) -> None: - layout = GridLayout() - await self.push_view(View(layout=layout)) + grid = await self.view.dock_grid() - layout.add_column("col", fraction=1, max_size=20) - layout.add_row("row", fraction=1, max_size=10) - layout.set_repeat(True, True) - layout.add_areas(center="col-2-start|col-4-end,row-2-start|row-3-end") - layout.set_align("stretch", "center") + grid.add_column("col", fraction=1, max_size=20) + grid.add_row("row", fraction=1, max_size=10) + grid.set_repeat(True, True) + grid.add_areas(center="col-2-start|col-4-end,row-2-start|row-3-end") + grid.set_align("stretch", "center") - layout.place(*(Placeholder() for _ in range(20)), center=Placeholder()) + grid.place(*(Placeholder() for _ in range(20)), center=Placeholder()) GridTest.run(title="Grid Test") diff --git a/examples/simple.py b/examples/simple.py index c27dc1dd4..2fe4a50b7 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -2,7 +2,6 @@ from rich.markdown import Markdown from textual import events from textual.app import App -from textual.views import DockView from textual.widgets import Header, Footer, Placeholder, ScrollView @@ -10,25 +9,17 @@ class MyApp(App): """An example of a very simple Textual App""" async def on_load(self, event: events.Load) -> None: - await self.bind("q,ctrl+c", "quit", "Quit") await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar") + await self.bind("q", "quit", "Quit") async def on_startup(self, event: events.Startup) -> None: - view = await self.push_view(DockView()) - - footer = Footer() - header = Header() body = ScrollView() - sidebar = Placeholder() - footer.add_key("b", "Toggle sidebar") - footer.add_key("q", "Quit") - - await view.dock(header, edge="top") - await view.dock(footer, edge="bottom") - await view.dock(sidebar, edge="left", size=30, name="sidebar") - await view.dock(body, edge="right") + await self.view.dock(Header(), edge="top") + await self.view.dock(Footer(), edge="bottom") + await self.view.dock(Placeholder(), edge="left", size=30, name="sidebar") + await self.view.dock(body, edge="right") async def get_markdown(filename: str) -> None: with open(filename, "rt") as fh: diff --git a/poetry.lock b/poetry.lock index d9b9d2281..a27c09438 100644 --- a/poetry.lock +++ b/poetry.lock @@ -369,11 +369,11 @@ pyparsing = ">=2.0.2" [[package]] name = "pathspec" -version = "0.8.1" +version = "0.9.0" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] name = "platformdirs" @@ -547,17 +547,24 @@ version = "10.6.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false -python-versions = ">=3.6,<4.0" +python-versions = "^3.6" +develop = false [package.dependencies] -colorama = ">=0.4.0,<0.5.0" -commonmark = ">=0.9.0,<0.10.0" -pygments = ">=2.6.0,<3.0.0" -typing-extensions = {version = ">=3.7.4,<4.0.0", markers = "python_version < \"3.8\""} +colorama = "^0.4.0" +commonmark = "^0.9.0" +pygments = "^2.6.0" +typing-extensions = {version = "^3.7.4", markers = "python_version < \"3.8\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] +[package.source] +type = "git" +url = "git@github.com:willmcgugan/rich" +reference = "link-id" +resolved_reference = "c4c00a2d0441519ced7ab2dead931341d9345eda" + [[package]] name = "six" version = "1.16.0" @@ -636,7 +643,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "b78b6843dbfa68dd86e0ec81c9f1980f57eb85c13d1df9b497c34762e8805699" +content-hash = "89e70da124ff666d5f911585eb2032d523499bcfe3c0efad9b2f5367cc64183b" [metadata.files] appdirs = [ @@ -865,8 +872,8 @@ packaging = [ {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, ] pathspec = [ - {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, - {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] platformdirs = [ {file = "platformdirs-2.0.2-py2.py3-none-any.whl", hash = "sha256:0b9547541f599d3d242078ae60b927b3e453f0ad52f58b4d4bc3be86aed3ec41"}, @@ -990,10 +997,7 @@ regex = [ {file = "regex-2021.7.6-cp39-cp39-win_amd64.whl", hash = "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c"}, {file = "regex-2021.7.6.tar.gz", hash = "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d"}, ] -rich = [ - {file = "rich-10.6.0-py3-none-any.whl", hash = "sha256:d3f72827cd5df13b2ef7f1a97f81ec65548d4fdeb92cef653234f227580bbb2a"}, - {file = "rich-10.6.0.tar.gz", hash = "sha256:128261b3e2419a4ef9c97066ccc2abbfb49fa7c5e89c3fe4056d00aa5e9c1e65"}, -] +rich = [] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, diff --git a/pyproject.toml b/pyproject.toml index 0d5973be1..0221da8e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.7" -rich = "^10.6.0" -#rich = {git = "git@github.com:willmcgugan/rich", rev = "height-fixes"} +#rich = "^10.6.0" +rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"} typing-extensions = { version = "^3.10.0", python = "<3.8" } [tool.poetry.dev-dependencies] diff --git a/src/textual/_input.py b/src/textual/_input.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/textual/app.py b/src/textual/app.py index 97d2696bc..cb59cca82 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -11,13 +11,11 @@ import rich.repr from rich.screen import Screen from rich import get_console from rich.console import Console, RenderableType -from rich.style import Style from rich.traceback import Traceback from . import events from . import actions from ._animator import Animator -from ._profile import timer from .binding import Bindings, NoBinding from .geometry import Point, Region from . import log @@ -92,7 +90,7 @@ class App(MessagePump): self.driver_class = driver_class or LinuxDriver self._title = title self._layout = DockLayout() - self._view_stack: list[View] = [] + self._view_stack: list[DockView] = [] self.children: set[MessagePump] = set() self.focused: Widget | None = None @@ -111,7 +109,7 @@ class App(MessagePump): self.log_file = open(log, "wt") if log else None - self.bindings.bind("ctrl+c", "quit") + self.bindings.bind("ctrl+c", "quit", show=False) super().__init__() @@ -130,7 +128,7 @@ class App(MessagePump): return self._animator @property - def view(self) -> View: + def view(self) -> DockView: return self._view_stack[-1] def log(self, *args: Any, verbosity: int = 0) -> None: @@ -143,9 +141,16 @@ class App(MessagePump): pass async def bind( - self, keys: str, action: str, description: str = "", show: bool = False + self, + keys: str, + action: str, + description: str = "", + show: bool = True, + key_display: str | None = None, ) -> None: - self.bindings.bind(keys, action, description, show=show) + self.bindings.bind( + keys, action, description, show=show, key_display=key_display + ) @classmethod def run( @@ -246,7 +251,7 @@ class App(MessagePump): log(f"driver={self.driver_class}") await self.dispatch_message(events.Load(sender=self)) - await self.push_view(View()) + await self.push_view(DockView()) try: driver.start_application_mode() @@ -345,14 +350,18 @@ class App(MessagePump): def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: return self.view.get_widget_at(x, y) + async def press(self, key: str) -> bool: + try: + binding = self.bindings.get_key(key) + except NoBinding: + return False + else: + await self.action(binding.action) + return True + async def on_event(self, event: events.Event) -> None: if isinstance(event, events.Key): - try: - binding = self.bindings.get_key(event.key) - except NoBinding: - pass - else: - await self.action(binding.action) + if await self.press(event.key): return await super().on_event(event) @@ -425,6 +434,9 @@ class App(MessagePump): async def on_resize(self, event: events.Resize) -> None: await self.view.post_message(event) + async def action_press(self, key: str) -> None: + await self.press(key) + async def action_quit(self) -> None: await self.shutdown() @@ -467,9 +479,9 @@ if __name__ == "__main__": """Just a test app.""" async def on_load(self, event: events.Load) -> None: - await self.bind("q,ctrl+c", "quit") - await self.bind("x", "bang") - await self.bind("b", "toggle_sidebar") + await self.bind("q,ctrl+c", "quit", "Exit app") + await self.bind("x", "bang", "Test error handling") + await self.bind("b", "toggle_sidebar", "Toggle sidebar") show_bar: Reactive[bool] = Reactive(False) @@ -486,8 +498,6 @@ if __name__ == "__main__": header = Header() footer = Footer() self.bar = Placeholder(name="left") - footer.add_key("b", "Toggle sidebar") - footer.add_key("q", "Quit") await view.dock(header, edge="top") await view.dock(footer, edge="bottom") diff --git a/src/textual/binding.py b/src/textual/binding.py index 86ba004f2..169e3669b 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -12,6 +12,7 @@ class Binding: action: str description: str show: bool = False + key_display: str | None = None class Bindings: @@ -20,16 +21,24 @@ class Bindings: def __init__(self) -> None: self.keys: dict[str, Binding] = {} + @property def shown_keys(self) -> list[Binding]: keys = [binding for binding in self.keys.values() if binding.show] return keys def bind( - self, keys: str, action: str, description: str = "", show: bool = False + self, + keys: str, + action: str, + description: str = "", + show: bool = True, + key_display: str | None = None, ) -> None: all_keys = [key.strip() for key in keys.split(",")] for key in all_keys: - self.keys[key] = Binding(key, action, description, show=show) + self.keys[key] = Binding( + key, action, description, show=show, key_display=key_display + ) def get_key(self, key: str) -> Binding: try: diff --git a/src/textual/view.py b/src/textual/view.py index 65555adc6..e03bd95eb 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -8,6 +8,7 @@ import rich.repr from rich.style import Style from . import events +from . import log from .layout import Layout, NoWidget from .layouts.dock import DockLayout from .geometry import Dimensions, Point, Region @@ -30,6 +31,8 @@ class View(Widget): self.size = Dimensions(0, 0) self.widgets: set[Widget] = set() self.named_widgets: dict[str, Widget] = {} + self._mouse_style: Style = Style() + self._mouse_widget: Widget | None = None super().__init__(name) background: Reactive[str] = Reactive("") @@ -148,6 +151,7 @@ class View(Widget): await self.refresh_layout() async def _on_mouse_move(self, event: events.MouseMove) -> None: + try: if self.app.mouse_captured: widget = self.app.mouse_captured @@ -157,8 +161,13 @@ class View(Widget): except NoWidget: await self.app.set_mouse_over(None) else: - await self.app.set_mouse_over(widget) + if event.style is not self._mouse_style and self._mouse_widget: + await self.app.broker_event("leave", event, self._mouse_widget) + await self.app.broker_event("enter", event, widget) + self._mouse_style = event.style + self._mouse_widget = widget + await self.app.set_mouse_over(widget) await widget.forward_event( events.MouseMove( self, diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py index 35d470bb8..1a7846e97 100644 --- a/src/textual/views/_dock_view.py +++ b/src/textual/views/_dock_view.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import cast, Optional from ..layouts.dock import DockLayout, Dock, DockEdge +from ..layouts.grid import GridLayout, GridAlign from ..view import View from ..widget import Widget @@ -23,7 +24,7 @@ class DockView(View): edge: DockEdge = "top", z: int = 0, size: int | None | DoNotSet = do_not_set, - name: str | None = None + name: str | None = None, ) -> None: dock = Dock(edge, widgets, z) @@ -38,3 +39,29 @@ class DockView(View): else: await self.mount(**{name: widget}) await self.refresh_layout() + + async def dock_grid( + self, + *, + edge: DockEdge = "top", + z: int = 0, + size: int | None | DoNotSet = do_not_set, + name: str | None = None, + gap: tuple[int, int] | int | None = None, + gutter: tuple[int, int] | int | None = None, + align: tuple[GridAlign, GridAlign] | None = None, + ) -> GridLayout: + + grid = GridLayout(gap=gap, gutter=gutter, align=align) + view = View(layout=grid) + dock = Dock(edge, (view,), z) + assert isinstance(self.layout, DockLayout) + self.layout.docks.append(dock) + if size is not do_not_set: + view.layout_size = cast(Optional[int], size) + if not self.is_mounted(view): + if name is None: + await self.mount(view) + else: + await self.mount(**{name: view}) + return grid diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index af06ceb7d..0a5e7d76a 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,8 +1,8 @@ from rich.console import RenderableType +from rich.style import Style from rich.text import Text import rich.repr -from .. import events from ..widget import Widget @@ -20,7 +20,6 @@ class Footer(Widget): self.keys.append((key, label)) def render(self) -> RenderableType: - text = Text( style="white on dark_green", no_wrap=True, @@ -28,7 +27,19 @@ class Footer(Widget): justify="left", end="", ) - for key, label in self.keys: - text.append(f" {key.upper()} ", style="default on default") - text.append(f" {label} ") + for binding in self.app.bindings.shown_keys: + key_display = ( + binding.key.upper() + if binding.key_display is None + else binding.key_display + ) + key_text = Text.assemble( + (f" {key_display} ", "default on default"), f" {binding.description} " + ) + key_text.stylize(Style(meta={"@click": f"app.press('{binding.key}')"})) + text.append_text(key_text) + # text.append(f" {key_display} ", style="default on default") + # text.append(f" {binding.description} ") + + # text.stylize(Style(meta={"@enter": "app.bell()"})) return text From 6ccadcb47f2520fb31ef54f6e3d5aeecc74449e7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 18 Jul 2021 17:43:42 +0100 Subject: [PATCH 03/36] clickable footer --- CHANGELOG.md | 6 +++++ poetry.lock | 8 +++---- src/textual/_linux_driver.py | 2 -- src/textual/layout.py | 5 +++- src/textual/view.py | 6 ----- src/textual/widget.py | 1 + src/textual/widgets/_footer.py | 42 +++++++++++++++++++++++++--------- 7 files changed, 46 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1594c754e..bdd2879b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.1.9] - Unreleased + +### Added + +- Added hover over and mouse click to activate keys in footer + ## [0.1.8] - 2021-07-17 ### Fixed diff --git a/poetry.lock b/poetry.lock index a27c09438..2e6fd2606 100644 --- a/poetry.lock +++ b/poetry.lock @@ -281,7 +281,7 @@ mkdocs = ">=1.1,<2.0" [[package]] name = "mkdocs-material" -version = "7.1.10" +version = "7.1.11" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -563,7 +563,7 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] type = "git" url = "git@github.com:willmcgugan/rich" reference = "link-id" -resolved_reference = "c4c00a2d0441519ced7ab2dead931341d9345eda" +resolved_reference = "f70eb3cac24f47443cdc571a05b255519deea9b4" [[package]] name = "six" @@ -823,8 +823,8 @@ mkdocs-autorefs = [ {file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.1.10.tar.gz", hash = "sha256:890e9be00bfbe4d22ccccbcde1bf9bad67a3ba495f2a7d2422ea4acb5099f014"}, - {file = "mkdocs_material-7.1.10-py2.py3-none-any.whl", hash = "sha256:92ff8c4a8e78555ef7b7ed0ba3043421d18971b48d066ea2cefb50e889fc66db"}, + {file = "mkdocs-material-7.1.11.tar.gz", hash = "sha256:cad3a693f1c28823370578e5b9c9aea418bddae0c7348ab734537391e9f2b1e5"}, + {file = "mkdocs_material-7.1.11-py2.py3-none-any.whl", hash = "sha256:0bcfb788020b72b0ebf5b2722ddf89534acaed8c3feb39c2d6dda239b49dec45"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index 831edc23d..72ebc5b49 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -198,8 +198,6 @@ class LinuxDriver(Driver): try: while not self.exit_event.is_set(): selector_events = selector.select(0.1) - if self.exit_event.is_set(): - break for _selector_key, mask in selector_events: if mask | selectors.EVENT_READ: unicode_data = decode(read(fileno, 1024)) diff --git a/src/textual/layout.py b/src/textual/layout.py index e79dedc50..ee17442d6 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -7,6 +7,7 @@ from operator import itemgetter import sys from typing import Iterable, Iterator, NamedTuple, TYPE_CHECKING +from rich import segment import rich.repr from rich.control import Control @@ -233,6 +234,7 @@ class Layout(ABC): return self._cuts def _get_renders(self, console: Console) -> Iterable[tuple[Region, Lines]]: + _rich_traceback_guard = True width = self.width height = self.height screen_region = Region(0, 0, width, height) @@ -248,7 +250,6 @@ class Layout(ABC): lines = console.render_lines( widget, console.options.update_dimensions(width, height) ) - log("rendered", widget) return lines for widget, region, _order in widget_regions: @@ -358,10 +359,12 @@ class Layout(ABC): def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None: if widget not in self.renders: return None + region, lines = self.renders[widget] new_lines = console.render_lines( widget, console.options.update_dimensions(region.width, region.height) ) + self.renders[widget] = (region, new_lines) update_lines = self.render(console, region).lines diff --git a/src/textual/view.py b/src/textual/view.py index e03bd95eb..c4d0efe5b 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -161,12 +161,6 @@ class View(Widget): except NoWidget: await self.app.set_mouse_over(None) else: - if event.style is not self._mouse_style and self._mouse_widget: - await self.app.broker_event("leave", event, self._mouse_widget) - await self.app.broker_event("enter", event, widget) - - self._mouse_style = event.style - self._mouse_widget = widget await self.app.set_mouse_over(widget) await widget.forward_event( events.MouseMove( diff --git a/src/textual/widget.py b/src/textual/widget.py index bc895f96e..f0b3c92a7 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -58,6 +58,7 @@ class Widget(MessagePump): self._layout_required = False self._animate: BoundAnimator | None = None self._reactive_watches: dict[str, Callable] = {} + self.highlight_style: Style | None = None super().__init__() diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 0a5e7d76a..c4904033d 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,8 +1,12 @@ +from __future__ import annotations + from rich.console import RenderableType from rich.style import Style from rich.text import Text import rich.repr +from .. import events +from ..reactive import Reactive from ..widget import Widget @@ -12,14 +16,19 @@ class Footer(Widget): self.keys: list[tuple[str, str]] = [] super().__init__() self.layout_size = 1 + self._key_text: Text | None = None + + highlight_key: Reactive[str | None] = Reactive(None) + + async def watch_highlight_key(self, value) -> None: + """If highlight key changes we need to regenerate the text.""" + self._key_text = None def __rich_repr__(self) -> rich.repr.RichReprResult: - yield "footer" + yield "keys", self.keys - def add_key(self, key: str, label: str) -> None: - self.keys.append((key, label)) - - def render(self) -> RenderableType: + def make_key_text(self) -> Text: + """Create text containing all the keys.""" text = Text( style="white on dark_green", no_wrap=True, @@ -33,13 +42,24 @@ class Footer(Widget): if binding.key_display is None else binding.key_display ) + hovered = self.highlight_key == binding.key key_text = Text.assemble( - (f" {key_display} ", "default on default"), f" {binding.description} " + (f" {key_display} ", "reverse" if hovered else "default on default"), + f" {binding.description} ", + meta={"@click": f"app.press('{binding.key}')", "key": binding.key}, ) - key_text.stylize(Style(meta={"@click": f"app.press('{binding.key}')"})) text.append_text(key_text) - # text.append(f" {key_display} ", style="default on default") - # text.append(f" {binding.description} ") - - # text.stylize(Style(meta={"@enter": "app.bell()"})) return text + + def render(self) -> RenderableType: + if self._key_text is None: + self._key_text = self.make_key_text() + return self._key_text + + async def on_mouse_move(self, event: events.MouseMove) -> None: + """Store any key we are moving over.""" + self.highlight_key = event.style.meta.get("key") + + async def on_leave(self, event: events.MouseMove) -> None: + """Clear any highlight when the mouse leave the widget""" + self.highlight_key = None From d9fb5162989ce745c49e7969aebc2c60b46fae45 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 18 Jul 2021 21:27:34 +0100 Subject: [PATCH 04/36] mount fix --- examples/animation.py | 2 +- src/textual/message_pump.py | 2 +- src/textual/view.py | 6 +----- src/textual/views/_dock_view.py | 3 ++- src/textual/widgets/_footer.py | 16 ++++++++-------- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/examples/animation.py b/examples/animation.py index 2f9119b59..18f4b0bf4 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -29,8 +29,8 @@ class SmoothApp(App): self.bar.layout_offset_x = -40 await self.view.dock(footer, edge="bottom") - await self.view.dock(self.bar, edge="left", size=40, z=1) await self.view.dock(Placeholder(), Placeholder(), edge="top") + await self.view.dock(self.bar, edge="left", size=40, z=1) SmoothApp.run() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index f0eff8033..0e6b39e28 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -59,7 +59,7 @@ class MessagePump: return self._parent and not self._parent._closed and not self._parent._closing def log(self, *args) -> None: - return self.app.log(args) + return self.app.log(*args) def set_parent(self, parent: MessagePump) -> None: self._parent = parent diff --git a/src/textual/view.py b/src/textual/view.py index c4d0efe5b..1fadb9a21 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -100,7 +100,7 @@ class View(Widget): self.require_repaint() async def refresh_layout(self) -> None: - + await self.layout.mount_all(self) if not self.size or not self.is_root_view: return @@ -108,10 +108,6 @@ class View(Widget): hidden, shown, resized = self.layout.reflow(width, height) self.app.refresh() - for widget in self.layout.get_widgets(): - if not self.is_mounted(widget): - await self.mount(widget) - for widget in hidden: widget.post_message_no_wait(events.Hide(self)) for widget in shown: diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py index 1a7846e97..8226f507f 100644 --- a/src/textual/views/_dock_view.py +++ b/src/textual/views/_dock_view.py @@ -53,7 +53,7 @@ class DockView(View): ) -> GridLayout: grid = GridLayout(gap=gap, gutter=gutter, align=align) - view = View(layout=grid) + view = View(layout=grid, name=name) dock = Dock(edge, (view,), z) assert isinstance(self.layout, DockLayout) self.layout.docks.append(dock) @@ -64,4 +64,5 @@ class DockView(View): await self.mount(view) else: await self.mount(**{name: view}) + await self.refresh_layout() return grid diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index c4904033d..0bf9c3e79 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -24,6 +24,14 @@ class Footer(Widget): """If highlight key changes we need to regenerate the text.""" self._key_text = None + async def on_mouse_move(self, event: events.MouseMove) -> None: + """Store any key we are moving over.""" + self.highlight_key = event.style.meta.get("key") + + async def on_leave(self, event: events.Leave) -> None: + """Clear any highlight when the mouse leave the widget""" + self.highlight_key = None + def __rich_repr__(self) -> rich.repr.RichReprResult: yield "keys", self.keys @@ -55,11 +63,3 @@ class Footer(Widget): if self._key_text is None: self._key_text = self.make_key_text() return self._key_text - - async def on_mouse_move(self, event: events.MouseMove) -> None: - """Store any key we are moving over.""" - self.highlight_key = event.style.meta.get("key") - - async def on_leave(self, event: events.MouseMove) -> None: - """Clear any highlight when the mouse leave the widget""" - self.highlight_key = None From 9bf83d56f0f333c3147b2cb5991dccc0793aef59 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Jul 2021 07:37:57 +0100 Subject: [PATCH 05/36] simplified events, added compute --- CHANGELOG.md | 4 + examples/animation.py | 2 +- examples/calculator.py | 186 ++++++++++++++++++-------------- examples/grid.py | 4 +- examples/grid_auto.py | 2 +- examples/simple.py | 2 +- src/textual/_animator.py | 7 +- src/textual/_linux_driver.py | 2 +- src/textual/_timer.py | 4 +- src/textual/app.py | 73 +++---------- src/textual/events.py | 17 --- src/textual/message_pump.py | 1 + src/textual/reactive.py | 36 ++++++- src/textual/view.py | 26 +++-- src/textual/views/__init__.py | 1 + src/textual/views/_dock_view.py | 18 ++-- src/textual/widgets/__init__.py | 12 ++- 17 files changed, 206 insertions(+), 191 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd2879b1..5d5070c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added hover over and mouse click to activate keys in footer +### Changed + +- Simplified events. Remove Startup event (use Mount) + ## [0.1.8] - 2021-07-17 ### Fixed diff --git a/examples/animation.py b/examples/animation.py index 18f4b0bf4..18b5ce2ff 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -22,7 +22,7 @@ class SmoothApp(App): """Called when user hits b key.""" self.show_bar = not self.show_bar - async def on_startup(self, event: events.Startup) -> None: + async def on_mount(self, event: events.Mount) -> None: """Build layout here.""" footer = Footer() self.bar = Placeholder(name="left") diff --git a/examples/calculator.py b/examples/calculator.py index 1a8174381..474b2f4c1 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -1,3 +1,9 @@ +""" + +A Textual app to create a fully working Calendar. + +""" + from decimal import Decimal from rich.align import Align @@ -5,23 +11,18 @@ from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.padding import Padding from rich.text import Text - from textual.app import App from textual import events -from textual.message import Message from textual.reactive import Reactive -from textual.view import View +from textual.views import GridView from textual.widget import Widget -from textual.widgets import Button -from textual.layouts.grid import GridLayout +from textual.widgets import Button, ButtonPressed try: from pyfiglet import Figlet except ImportError: print("Please install pyfiglet to run this example") - import sys - - sys.exit() + raise class FigletText: @@ -33,6 +34,7 @@ class FigletText: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: + """Build a Rich renderable to render the Figlet text.""" size = min(options.max_width / 2, options.max_height) if size < 4: yield Text(self.text, style="bold") @@ -55,6 +57,7 @@ class Numbers(Widget): value: Reactive[str] = Reactive("0") def render(self) -> RenderableType: + """Build a Rich renderable to render the calculator display.""" return Padding( Align.right(FigletText(self.value), vertical="middle"), (0, 1), @@ -62,132 +65,149 @@ class Numbers(Widget): ) -class CalculatorApp(App): +class Calculator(GridView): """A working calculator app.""" - async def on_load(self, event: events.Load) -> None: - """Sent when the app starts, but before displaying anything.""" + DARK = "white on rgb(51,51,51)" + LIGHT = "black on rgb(165,165,165)" + YELLOW = "white on rgb(255,159,7)" + BUTTON_STYLES = { + "AC": LIGHT, + "C": LIGHT, + "+/-": LIGHT, + "%": LIGHT, + "/": YELLOW, + "X": YELLOW, + "-": YELLOW, + "+": YELLOW, + "=": YELLOW, + } + + display: Reactive[str] = Reactive("0") + show_ac: Reactive[bool] = Reactive(True) + + async def watch_display(self, value: str) -> None: + """Called when self.display is modified.""" + # self.numbers is a widget that displays the calculator result + # Setting the attribute value changes the display + # This allows us to write self.display = "100" to update the display + self.numbers.value = value + + async def compute_show_ac(self) -> bool: + """Compute show_ac reactive value.""" + # Condition to show AC button over C + return self.value in ("", "0") and self.display == "0" + + async def watch_show_ac(self, show_ac: bool) -> None: + """When the show_ac attribute change we need to update the buttons.""" + # Show AC and hide C or vice versa + self.c.visible = not show_ac + self.ac.visible = show_ac + + async def on_mount(self, event: events.Mount) -> None: + """Event when widget is first mounted (added to a parent view).""" + + # Attributes to store the current calculation self.left = Decimal("0") self.right = Decimal("0") self.value = "" self.operator = "+" + + # The calculator display self.numbers = Numbers() def make_button(text: str, style: str) -> Button: """Create a button with the given Figlet label.""" return Button(FigletText(text), style=style, name=text) - dark = "white on rgb(51,51,51)" - light = "black on rgb(165,165,165)" - yellow = "white on rgb(255,159,7)" - - button_styles = { - "AC": light, - "C": light, - "+/-": light, - "%": light, - "/": yellow, - "X": yellow, - "-": yellow, - "+": yellow, - "=": yellow, - } - # Make all the buttons self.buttons = { - name: make_button(name, button_styles.get(name, dark)) + name: make_button(name, self.BUTTON_STYLES.get(name, self.DARK)) for name in "+/-,%,/,7,8,9,X,4,5,6,-,1,2,3,+,.,=".split(",") } - self.zero = make_button("0", dark) - self.ac = make_button("AC", light) - self.c = make_button("C", light) + # Buttons that have to be treated specially + self.zero = make_button("0", self.DARK) + self.ac = make_button("AC", self.LIGHT) + self.c = make_button("C", self.LIGHT) self.c.visible = False - async def on_startup(self, event: events.Startup) -> None: - """Sent when the app has gone full screen.""" - - # Create a grid layout - grid = await self.view.dock_grid( - gap=(2, 1), gutter=1, align=("center", "center") - ) + # Set basic grid settings + self.grid.set_gap(2, 1) + self.grid.set_gutter(1) + self.grid.set_align("center", "center") # Create rows / columns / areas - grid.add_column("col", max_size=30, repeat=4) - grid.add_row("numbers", max_size=15) - grid.add_row("row", max_size=15, repeat=5) - grid.add_areas( + self.grid.add_column("col", max_size=30, repeat=4) + self.grid.add_row("numbers", max_size=15) + self.grid.add_row("row", max_size=15, repeat=5) + self.grid.add_areas( clear="col1,row1", numbers="col1-start|col4-end,numbers", zero="col1-start|col2-end,row5", ) # Place out widgets in to the layout - grid.place(clear=self.c) - grid.place( + self.grid.place(clear=self.c) + self.grid.place( *self.buttons.values(), clear=self.ac, numbers=self.numbers, zero=self.zero ) - async def message_button_pressed(self, message: Message) -> None: + async def message_button_pressed(self, message: ButtonPressed) -> None: """A message sent by the button widget""" assert isinstance(message.sender, Button) button_name = message.sender.name - def do_math() -> bool: - operator = self.operator - right = self.right - if operator == "+": - self.left += right - elif operator == "-": - self.left -= right - elif operator == "/": - try: - self.left /= right - except ZeroDivisionError: - self.numbers.value = "Error" - return False - elif operator == "X": - self.left *= right - return True + def do_math() -> None: + """Does the math: LEFT OPERATOR RIGHT""" + try: + if self.operator == "+": + self.left += self.right + elif self.operator == "-": + self.left -= self.right + elif self.operator == "/": + self.left /= self.right + elif self.operator == "X": + self.left *= self.right + self.display = str(self.left) + self.value = "" + except ZeroDivisionError: + self.display = "Error" if button_name.isdigit(): - self.value = self.value.lstrip("0") + button_name - self.numbers.value = self.value + self.display = self.value = self.value.lstrip("0") + button_name elif button_name == "+/-": - self.value = str(Decimal(self.value or "0") * -1) - self.numbers.value = self.value + self.display = self.value = str(Decimal(self.value or "0") * -1) elif button_name == "%": - self.value = str(Decimal(self.value or "0") / Decimal(100)) - self.numbers.value = self.value + self.display = self.value = str(Decimal(self.value or "0") / Decimal(100)) elif button_name == ".": if "." not in self.value: - self.value += "." - self.numbers.value = self.value + self.display = self.value = self.value + "." elif button_name == "AC": self.value = "" self.left = self.right = Decimal(0) self.operator = "+" - self.numbers.value = "0" + self.display = "0" elif button_name == "C": self.value = "" - self.numbers.value = "0" + self.display = "0" elif button_name in ("+", "-", "/", "X"): self.right = Decimal(self.value or "0") - if do_math(): - self.numbers.value = str(self.left) - self.value = "" + do_math() self.operator = button_name elif button_name == "=": if self.value: - self.right = Decimal(self.value or "0") - if do_math(): - self.numbers.value = str(self.left) - self.value = "" - - show_ac = self.value in ("", "0") and self.numbers.value == "0" - self.c.visible = not show_ac - self.ac.visible = show_ac + self.right = Decimal(self.value) + do_math() -CalculatorApp.run(title="Calculator Test") +class CalculatorApp(App): + """The Calculator Application""" + + async def on_mount(self, event: events.Mount) -> None: + """Mount the calculator widget.""" + await self.view.dock(Calculator()) + + +CalculatorApp.run(title="Calculator Test", log="textual.log") diff --git a/examples/grid.py b/examples/grid.py index 83f30dde3..c48eb0c94 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -1,15 +1,13 @@ from textual.app import App from textual import events -from textual.view import View from textual.widgets import Placeholder -from textual.layouts.grid import GridLayout class GridTest(App): async def on_load(self, event: events.Load) -> None: await self.bind("q,ctrl+c", "quit", "Quit") - async def on_startup(self, event: events.Startup) -> None: + async def on_mount(self, event: events.Mount) -> None: grid = await self.view.dock_grid() diff --git a/examples/grid_auto.py b/examples/grid_auto.py index 31881f9ef..3c59437b7 100644 --- a/examples/grid_auto.py +++ b/examples/grid_auto.py @@ -9,7 +9,7 @@ class GridTest(App): async def on_load(self, event: events.Load) -> None: await self.bind("q,ctrl+c", "quit", "Quit") - async def on_startup(self, event: events.Startup) -> None: + async def on_mount(self, event: events.Mount) -> None: grid = await self.view.dock_grid() diff --git a/examples/simple.py b/examples/simple.py index 2fe4a50b7..f5d647312 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -12,7 +12,7 @@ class MyApp(App): await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar") await self.bind("q", "quit", "Quit") - async def on_startup(self, event: events.Startup) -> None: + async def on_mount(self, event: events.Mount) -> None: body = ScrollView() diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 6dd051580..d05d718cc 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -114,7 +114,12 @@ 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, name="Animator", callback=self + target, + 1 / frames_per_second, + target, + name="Animator", + callback=self, + pause=True, ) self._timer_task: asyncio.Task | None = None diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index 72ebc5b49..c6c0f462e 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -220,7 +220,7 @@ if __name__ == "__main__": from .app import App class MyApp(App): - async def on_startup(self, event: events.Startup) -> None: + async def on_mount(self, event: events.Mount) -> None: self.set_timer(5, callback=self.close_messages) MyApp.run() diff --git a/src/textual/_timer.py b/src/textual/_timer.py index af601e838..15f9d303c 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -31,6 +31,7 @@ class Timer: callback: TimerCallback | None = None, repeat: int = None, skip: bool = False, + pause: bool = False, ) -> None: self._target_repr = repr(event_target) self._target = weakref.ref(event_target) @@ -43,7 +44,8 @@ class Timer: self._skip = skip self._stop_event = Event() self._active = Event() - self._active.set() + if not pause: + self._active.set() def __rich_repr__(self) -> RichReprResult: yield self._interval diff --git a/src/textual/app.py b/src/textual/app.py index cb59cca82..235825b23 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -175,10 +175,9 @@ class App(MessagePump): asyncio.run(run_app()) async def push_view(self, view: ViewType) -> ViewType: - await self.register(view) - view.set_parent(self) + self.register(view, self) self._view_stack.append(view) - await view.post_message(events.Mount(sender=self)) + # await view.post_message(events.Mount(sender=self)) return view def on_keyboard_interupt(self) -> None: @@ -251,6 +250,7 @@ class App(MessagePump): log(f"driver={self.driver_class}") await self.dispatch_message(events.Load(sender=self)) + await self.post_message(events.Mount(self)) await self.push_view(DockView()) try: @@ -262,7 +262,6 @@ class App(MessagePump): try: self.title = self._title - await self.post_message(events.Startup(sender=self)) self.require_layout() await self.animator.start() @@ -305,10 +304,14 @@ class App(MessagePump): async def message_update(self, message: Message) -> None: self.refresh() - async def register(self, child: MessagePump) -> None: - self.children.add(child) - child.start_messages() - await child.post_message(events.Created(sender=self)) + def register(self, child: MessagePump, parent: MessagePump) -> bool: + if child not in self.children: + self.children.add(child) + child.set_parent(parent) + child.start_messages() + child.post_message_no_wait(events.Mount(sender=parent)) + return True + return False async def close_all(self) -> None: while self.children: @@ -465,21 +468,12 @@ if __name__ == "__main__": import os - # from rich.console import Console - - # console = Console() - # console.print(scroll_bar, height=10) - # console.print(scroll_view, height=20) - - # import sys - - # sys.exit() - class MyApp(App): """Just a test app.""" async def on_load(self, event: events.Load) -> None: - await self.bind("q,ctrl+c", "quit", "Exit app") + await self.bind("ctrl+c", "quit", show=False) + await self.bind("q", "quit", "Quit") await self.bind("x", "bang", "Test error handling") await self.bind("b", "toggle_sidebar", "Toggle sidebar") @@ -491,7 +485,7 @@ if __name__ == "__main__": async def action_toggle_sidebar(self) -> None: self.show_bar = not self.show_bar - async def on_startup(self, event: events.Startup) -> None: + async def on_mount(self, event: events.Mount) -> None: view = await self.push_view(DockView()) @@ -504,47 +498,8 @@ if __name__ == "__main__": await view.dock(self.bar, edge="left", size=40, z=1) self.bar.layout_offset_x = -40 - # await view.dock(Placeholder(), Placeholder(), edge="top") - sub_view = DockView() await sub_view.dock(Placeholder(), Placeholder(), edge="top") await view.dock(sub_view, edge="left") - # self.refresh() - - # footer = Footer() - # footer.add_key("b", "Toggle sidebar") - # footer.add_key("q", "Quit") - - # readme_path = os.path.join( - # os.path.dirname(os.path.abspath(__file__)), "richreadme.md" - # ) - # # scroll_view = LayoutView() - # # scroll_bar = ScrollBar() - # with open(readme_path, "rt") as fh: - # readme = Markdown(fh.read(), hyperlinks=True, code_theme="fruity") - # # scroll_view.layout.split_column( - # # Layout(readme, ratio=1), Layout(scroll_bar, ratio=2, size=2) - # # ) - # layout = Layout() - # layout.split_column(Layout(name="l1"), Layout(name="l2")) - # # sub_view = LayoutView(name="Sub view", layout=layout) - - # sub_view = ScrollView(readme) - - # # await sub_view.mount_all(l1=Placeholder(), l2=Placeholder()) - - # await self.view.mount_all( - # header=Header(self.title), - # left=Placeholder(), - # body=sub_view, - # footer=footer, - # ) - - # app = MyApp() - # from rich.console import Console - - # console = Console() - # console.print(app._view_stack[0], height=30) - # console.print(app._view_stack) MyApp.run(log="textual.log") diff --git a/src/textual/events.py b/src/textual/events.py index 280c0c245..3c8a25ca8 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -64,23 +64,6 @@ class Load(Event): """ -class Startup(Event): - """ - Sent when the app has enabled application mode. - - Use this event to create views and widgets. - - """ - - -class Created(Event): - pass - - -class Updated(Event): - """Indicates the sender was updated and needs a refresh.""" - - class Idle(Event): """Sent when there are no more items in the message queue. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 0e6b39e28..3cd2142cd 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -13,6 +13,7 @@ from . import log from ._timer import Timer, TimerCallback from ._context import active_app from .message import Message +from .reactive import Reactive if TYPE_CHECKING: diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 87a3c6cf8..da5fc6f66 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -43,6 +43,15 @@ class Reactive(Generic[ReactiveType]): self._first = True def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: + + if hasattr(owner, f"compute_{name}"): + try: + computes = getattr(owner, "__computes") + except AttributeError: + computes = [] + setattr(owner, "__computes", computes) + computes.append(name) + self.name = name self.internal_name = f"__{name}" setattr(owner, self.internal_name, self._default) @@ -76,19 +85,42 @@ class Reactive(Generic[ReactiveType]): internal_name = f"__{name}" value = getattr(obj, internal_name) + async def update_watcher( + obj: Reactable, watch_function: Callable, value + ) -> None: + _rich_traceback_guard = True + await watch_function(value) + await Reactive.compute(obj) + watch_function = getattr(obj, f"watch_{name}", None) if callable(watch_function): obj.post_message_no_wait( - events.Callback(obj, callback=partial(watch_function, value)) + events.Callback( + obj, callback=partial(update_watcher, obj, watch_function, value) + ) ) watcher_name = f"__{name}_watchers" watchers = getattr(obj, watcher_name, ()) for watcher in watchers: obj.post_message_no_wait( - events.Callback(obj, callback=partial(watcher, value)) + events.Callback( + obj, callback=partial(update_watcher, obj, watcher, value) + ) ) + @classmethod + async def compute(cls, obj: Reactable) -> None: + _rich_traceback_guard = True + computes = getattr(obj, "__computes", []) + for compute in computes: + try: + compute_method = getattr(obj, f"compute_{compute}") + except AttributeError: + continue + value = await compute_method() + setattr(obj, compute, value) + def watch( obj: Reactable, attribute_name: str, callback: Callable[[Any], Awaitable[None]] diff --git a/src/textual/view.py b/src/textual/view.py index 1fadb9a21..24e324fc4 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -1,7 +1,7 @@ from __future__ import annotations from itertools import chain -from typing import Iterable, TYPE_CHECKING +from typing import Callable, Iterable, ClassVar, TYPE_CHECKING from rich.console import Console, ConsoleOptions, RenderResult, RenderableType import rich.repr @@ -24,8 +24,11 @@ if TYPE_CHECKING: @rich.repr.auto class View(Widget): + + layout_factory: ClassVar[Callable[[], Layout]] + def __init__(self, layout: Layout = None, name: str | None = None) -> None: - self.layout: Layout = layout or DockLayout() + self.layout: Layout = layout or self.layout_factory() self.mouse_over: Widget | None = None self.focused: Widget | None = None self.size = Dimensions(0, 0) @@ -33,7 +36,14 @@ class View(Widget): self.named_widgets: dict[str, Widget] = {} self._mouse_style: Style = Style() self._mouse_widget: Widget | None = None - super().__init__(name) + super().__init__(name=name) + + def __init_subclass__( + cls, layout: Callable[[], Layout] | None = None, **kwargs + ) -> None: + if layout is not None: + cls.layout_factory = layout + super().__init_subclass__(**kwargs) background: Reactive[str] = Reactive("") @@ -90,12 +100,10 @@ class View(Widget): ) for name, widget in name_widgets: name = name or widget.name - if name: - self.named_widgets[name] = widget - await self.app.register(widget) - widget.set_parent(self) - await widget.post_message(events.Mount(sender=self)) - self.widgets.add(widget) + if self.app.register(widget, self): + if name: + self.named_widgets[name] = widget + self.widgets.add(widget) self.require_repaint() diff --git a/src/textual/views/__init__.py b/src/textual/views/__init__.py index b98922f15..dd1c4dfd4 100644 --- a/src/textual/views/__init__.py +++ b/src/textual/views/__init__.py @@ -1 +1,2 @@ from ._dock_view import DockView, Dock, DockEdge +from ._grid_view import GridView diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py index 8226f507f..e468eab50 100644 --- a/src/textual/views/_dock_view.py +++ b/src/textual/views/_dock_view.py @@ -33,11 +33,10 @@ class DockView(View): for widget in widgets: if size is not do_not_set: widget.layout_size = cast(Optional[int], size) - if not self.is_mounted(widget): - if name is None: - await self.mount(widget) - else: - await self.mount(**{name: widget}) + if name is None: + await self.mount(widget) + else: + await self.mount(**{name: widget}) await self.refresh_layout() async def dock_grid( @@ -59,10 +58,9 @@ class DockView(View): self.layout.docks.append(dock) if size is not do_not_set: view.layout_size = cast(Optional[int], size) - if not self.is_mounted(view): - if name is None: - await self.mount(view) - else: - await self.mount(**{name: view}) + if name is None: + await self.mount(view) + else: + await self.mount(**{name: view}) await self.refresh_layout() return grid diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index dfc36eb55..1640bf07a 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -1,8 +1,16 @@ from ._footer import Footer from ._header import Header -from ._button import Button +from ._button import Button, ButtonPressed from ._placeholder import Placeholder from ._scroll_view import ScrollView from ._static import Static -__all__ = ["Footer", "Header", "Button", "Placeholder", "ScrollView", "Static"] +__all__ = [ + "Footer", + "Header", + "Button", + "ButtonPressed", + "Placeholder", + "ScrollView", + "Static", +] From 11d213a3a2f37c4c31c41a418c8a61da56f91c7d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Jul 2021 07:38:13 +0100 Subject: [PATCH 06/36] added grid view --- src/textual/views/_grid_view.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/textual/views/_grid_view.py diff --git a/src/textual/views/_grid_view.py b/src/textual/views/_grid_view.py new file mode 100644 index 000000000..e90ebda9b --- /dev/null +++ b/src/textual/views/_grid_view.py @@ -0,0 +1,9 @@ +from ..view import View +from ..layouts.grid import GridLayout + + +class GridView(View, layout=GridLayout): + @property + def grid(self) -> GridLayout: + assert isinstance(self.layout, GridLayout), repr(self.layout_factory) + return self.layout From 42841c5b2c161ed172048de95f7ea9bbb18a69f0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Jul 2021 08:11:03 +0100 Subject: [PATCH 07/36] typing --- src/textual/layouts/grid.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 9513c9bb7..36f2f5f74 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -290,6 +290,7 @@ class GridLayout(Layout): size: int, edges: list[GridOptions], gap: int, repeat: bool ) -> Iterable[tuple[int, int]]: total_gap = gap * (len(edges) - 1) + tracks: Iterable[int] tracks = [ track if edge.max_size is None else min(edge.max_size, track) for track, edge in zip(layout_resolve(size - total_gap, edges), edges) @@ -314,7 +315,7 @@ class GridLayout(Layout): max_size = 0 tracks: dict[str, tuple[int, int]] = {} - counts = defaultdict(int) + counts: dict[str, int] = defaultdict(int) if repeat: names = [] for index, (name, (start, end)) in enumerate(spans): @@ -373,7 +374,7 @@ class GridLayout(Layout): (col, row) for col, row in product(range(column_count), range(row_count)) } - map = {} + map: dict[Widget, OrderedRegion] = {} order = 1 from_corners = Region.from_corners gutter = Point(self.column_gutter, self.row_gutter) From 36b262120232c3ed2fbb839f7009ed13ee76fb31 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Jul 2021 20:04:49 +0100 Subject: [PATCH 08/36] eol --- examples/code_viewer.py | 48 +++++++ poetry.lock | 14 +-- src/textual/_event_broker.py | 2 +- src/textual/actions.py | 8 +- src/textual/app.py | 14 ++- src/textual/layout.py | 1 + src/textual/layouts/grid.py | 30 ++--- src/textual/page.py | 2 +- src/textual/views/_grid_view.py | 2 +- src/textual/widget.py | 4 +- src/textual/widgets/__init__.py | 12 +- src/textual/widgets/_directory_tree.py | 99 +++++++++++++++ src/textual/widgets/_header.py | 2 +- src/textual/widgets/_scroll_view.py | 19 ++- src/textual/widgets/_tree_control.py | 166 +++++++++++++++++++++++++ 15 files changed, 371 insertions(+), 52 deletions(-) create mode 100644 examples/code_viewer.py create mode 100644 src/textual/widgets/_directory_tree.py create mode 100644 src/textual/widgets/_tree_control.py diff --git a/examples/code_viewer.py b/examples/code_viewer.py new file mode 100644 index 000000000..6fbdbafbf --- /dev/null +++ b/examples/code_viewer.py @@ -0,0 +1,48 @@ +import os +import sys + +from rich.syntax import Syntax + +from textual import events +from textual.app import App +from textual.widgets import Header, Footer, FileClick, ScrollView, DirectoryTree + + +class MyApp(App): + """An example of a very simple Textual App""" + + async def on_load(self, event: events.Load) -> None: + await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar") + await self.bind("q", "quit", "Quit") + + try: + self.path = sys.argv[1] + except IndexError: + self.path = os.path.abspath( + os.path.join(os.path.basename(__file__), "../../") + ) + + async def on_mount(self, event: events.Mount) -> None: + + self.body = ScrollView() + self.directory = DirectoryTree(self.path, "Code") + + await self.view.dock(Header(), edge="top") + await self.view.dock(Footer(), edge="bottom") + await self.view.dock(self.directory, edge="left", size=32, name="sidebar") + await self.view.dock(self.body, edge="right") + + async def message_file_click(self, message: FileClick) -> None: + syntax = Syntax.from_path( + message.path, + line_numbers=True, + word_wrap=True, + indent_guides=True, + theme="monokai", + ) + self.app.sub_title = os.path.basename(message.path) + await self.body.update(syntax) + self.body.home() + + +MyApp.run(title="Code Viewer", log="textual.log") diff --git a/poetry.lock b/poetry.lock index 2e6fd2606..321022a5c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -246,7 +246,7 @@ python-versions = ">=3.6" [[package]] name = "mkdocs" -version = "1.2.1" +version = "1.2.2" description = "Project documentation with Markdown." category = "dev" optional = false @@ -281,7 +281,7 @@ mkdocs = ">=1.1,<2.0" [[package]] name = "mkdocs-material" -version = "7.1.11" +version = "7.2.0" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -563,7 +563,7 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] type = "git" url = "git@github.com:willmcgugan/rich" reference = "link-id" -resolved_reference = "f70eb3cac24f47443cdc571a05b255519deea9b4" +resolved_reference = "8ae1d4ad0a36a84485acccd1768f1f2a122f2277" [[package]] name = "six" @@ -815,16 +815,16 @@ mergedeep = [ {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, ] mkdocs = [ - {file = "mkdocs-1.2.1-py3-none-any.whl", hash = "sha256:11141126e5896dd9d279b3e4814eb488e409a0990fb638856255020406a8e2e7"}, - {file = "mkdocs-1.2.1.tar.gz", hash = "sha256:6e0ea175366e3a50d334597b0bc042b8cebd512398cdd3f6f34842d0ef524905"}, + {file = "mkdocs-1.2.2-py3-none-any.whl", hash = "sha256:d019ff8e17ec746afeb54eb9eb4112b5e959597aebc971da46a5c9486137f0ff"}, + {file = "mkdocs-1.2.2.tar.gz", hash = "sha256:a334f5bd98ec960638511366eb8c5abc9c99b9083a0ed2401d8791b112d6b078"}, ] mkdocs-autorefs = [ {file = "mkdocs-autorefs-0.2.1.tar.gz", hash = "sha256:b8156d653ed91356e71675ce1fa1186d2b2c2085050012522895c9aa98fca3e5"}, {file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.1.11.tar.gz", hash = "sha256:cad3a693f1c28823370578e5b9c9aea418bddae0c7348ab734537391e9f2b1e5"}, - {file = "mkdocs_material-7.1.11-py2.py3-none-any.whl", hash = "sha256:0bcfb788020b72b0ebf5b2722ddf89534acaed8c3feb39c2d6dda239b49dec45"}, + {file = "mkdocs-material-7.2.0.tar.gz", hash = "sha256:9f43c5874e119b312a6f369ef363815c11f182b5cdeff4a3426615ebc4664ace"}, + {file = "mkdocs_material-7.2.0-py2.py3-none-any.whl", hash = "sha256:8b3750857e168a9ca20be34890791817090b016248a39be45069fab5343f1dc0"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, diff --git a/src/textual/_event_broker.py b/src/textual/_event_broker.py index 865b98de2..a5bd074ac 100644 --- a/src/textual/_event_broker.py +++ b/src/textual/_event_broker.py @@ -9,7 +9,7 @@ class NoHandler(Exception): class HandlerArguments(NamedTuple): modifiers: set[str] - action: str + action: Any def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArguments: diff --git a/src/textual/actions.py b/src/textual/actions.py index b8073859f..41839834b 100644 --- a/src/textual/actions.py +++ b/src/textual/actions.py @@ -18,8 +18,10 @@ def parse(action: str) -> tuple[str, tuple[Any, ...]]: action_name, action_params_str = params_match.groups() try: action_params = ast.literal_eval(action_params_str) - except Exception as error: - raise ActionError(str(error)) + except Exception: + raise ActionError( + f"unable to parse {action_params_str!r} in action {action!r}" + ) else: action_name = action action_params = () @@ -32,6 +34,8 @@ def parse(action: str) -> tuple[str, tuple[Any, ...]]: if __name__ == "__main__": + print(parse("foo")) + print(parse("view.toggle('side')")) print(parse("view.toggle")) diff --git a/src/textual/app.py b/src/textual/app.py index 235825b23..cdb89c8ad 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -392,6 +392,7 @@ class App(MessagePump): Args: action (str): Action encoded in a string. """ + self.log(action, default_namespace) target, params = actions.parse(action) if "." in target: destination, action_name = target.split(".", 1) @@ -400,7 +401,7 @@ class App(MessagePump): action_target = getattr(self, destination) else: action_target = default_namespace or self - action_name = action + action_name = target log("ACTION", action_target, action_name) await self.dispatch_action(action_target, action_name, params) @@ -425,9 +426,14 @@ class App(MessagePump): modifiers, action = extract_handler_actions(event_name, style.meta) except NoHandler: return False - await self.action( - action, default_namespace=default_namespace, modifiers=modifiers - ) + if isinstance(action, str): + await self.action( + action, default_namespace=default_namespace, modifiers=modifiers + ) + elif isinstance(action, Callable): + await action() + else: + return False return True async def on_shutdown_request(self, event: events.ShutdownRequest) -> None: diff --git a/src/textual/layout.py b/src/textual/layout.py index ee17442d6..ea3def14e 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -101,6 +101,7 @@ class Layout(ABC): def reflow(self, width: int, height: int) -> ReflowResult: self.reset() + log(" REFLOW", self) map = self.generate_map(width, height) self._require_update = False diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 36f2f5f74..afeb7ecfd 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -87,36 +87,26 @@ class GridLayout(Layout): return column_name not in self.hidden_columns def show_row(self, row_name: str, visible: bool = True) -> bool: - changed = False + changed = (row_name in self.hidden_rows) == visible if visible: - if not self.is_row_visible(row_name): - self.require_update() - changed = True self.hidden_rows.discard(row_name) - self.require_update() else: - if self.is_row_visible(row_name): - self.require_update() - changed = True self.hidden_rows.add(row_name) + if changed: self.require_update() - return changed + return True + return False def show_column(self, column_name: str, visible: bool = True) -> bool: - changed = False + changed = (column_name in self.hidden_columns) == visible if visible: - if not self.is_column_visible(column_name): - self.require_update() - changed = True - self.hidden_rows.discard(column_name) - self.require_update() + self.hidden_columns.discard(column_name) else: - if self.is_column_visible(column_name): - self.require_update() - changed = True - self.hidden_rows.add(column_name) + self.hidden_columns.add(column_name) + if changed: self.require_update() - return changed + return True + return False def add_column( self, diff --git a/src/textual/page.py b/src/textual/page.py index decb90b8e..2e759654e 100644 --- a/src/textual/page.py +++ b/src/textual/page.py @@ -27,7 +27,7 @@ class PageRender: width: int | None = None, height: int | None = None, style: StyleType = "", - padding: PaddingDimensions = 1, + padding: PaddingDimensions = (0, 0), ) -> None: self.page = page self.renderable = renderable diff --git a/src/textual/views/_grid_view.py b/src/textual/views/_grid_view.py index e90ebda9b..af04b6d3f 100644 --- a/src/textual/views/_grid_view.py +++ b/src/textual/views/_grid_view.py @@ -5,5 +5,5 @@ from ..layouts.grid import GridLayout class GridView(View, layout=GridLayout): @property def grid(self) -> GridLayout: - assert isinstance(self.layout, GridLayout), repr(self.layout_factory) + assert isinstance(self.layout, GridLayout) return self.layout diff --git a/src/textual/widget.py b/src/textual/widget.py index f0b3c92a7..edea59421 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,9 +1,9 @@ from __future__ import annotations -from functools import partial from logging import getLogger from typing import ( Any, + Awaitable, TYPE_CHECKING, Callable, ClassVar, @@ -85,7 +85,7 @@ class Widget(MessagePump): def __rich__(self) -> RenderableType: return self.render() - def watch(self, attribute_name, callback: Callable[[Any], None]) -> None: + def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: watch(self, attribute_name, callback) @property diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 1640bf07a..3488b26e3 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -4,13 +4,21 @@ from ._button import Button, ButtonPressed from ._placeholder import Placeholder from ._scroll_view import ScrollView from ._static import Static +from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID +from ._directory_tree import DirectoryTree, FileClick __all__ = [ - "Footer", - "Header", "Button", "ButtonPressed", + "DirectoryTree", + "FileClick", + "Footer", + "Header", "Placeholder", "ScrollView", "Static", + "TreeClick", + "TreeControl", + "TreeNode", + "NodeID", ] diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py new file mode 100644 index 000000000..d21f05aa7 --- /dev/null +++ b/src/textual/widgets/_directory_tree.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from dataclasses import dataclass +from os import scandir +import os.path + +from rich.console import RenderableType +import rich.repr +from rich.text import Text +from rich.tree import Tree + +from .. import events +from ..message import Message +from .._types import MessageTarget +from . import TreeControl, TreeClick, TreeNode, NodeID + + +@dataclass +class DirEntry: + path: str + is_dir: bool + + +@rich.repr.auto +class FileClick(Message, bubble=True): + def __init__(self, sender: MessageTarget, path: str) -> None: + self.path = path + super().__init__(sender) + + +class DirectoryTree(TreeControl[DirEntry]): + def __init__(self, path: str, name: str = None) -> None: + self.path = path.rstrip("/") + label = os.path.basename(self.path) + data = DirEntry(path, True) + super().__init__(label, name=name, data=data) + self.root.tree.guide_style = "black" + + async def watch_hover_node(self, hover_node: NodeID) -> None: + for node in self.nodes.values(): + node.tree.guide_style = ( + "bold not dim red" if node.id == hover_node else "black" + ) + + def render_node(self, node: TreeNode[DirEntry]) -> RenderableType: + meta = {"@click": f"click_label({node.id})", "tree_node": node.id} + label = Text(node.label) if isinstance(node.label, str) else node.label + if node.id == self.hover_node: + label.stylize("underline") + if node.data.is_dir: + label.stylize("bold magenta") + icon = "📂" if node.expanded else "📁" + else: + label.stylize("bright_green") + icon = "📄" + label.highlight_regex(r"\..*$", "green") + + if label.plain.startswith("."): + label.stylize("dim") + + icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label + icon_label.apply_meta(meta) + return icon_label + + async def on_mount(self, event: events.Mount) -> None: + await self.load_directory(self.root) + + async def load_directory(self, node: TreeNode[DirEntry]): + path = node.data.path + directory = sorted( + list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name) + ) + for entry in directory: + await node.add(entry.name, DirEntry(entry.path, entry.is_dir())) + node.loaded = True + await node.expand() + self.require_repaint() + + async def message_tree_click(self, message: TreeClick[DirEntry]) -> None: + dir_entry = message.node.data + if not dir_entry.is_dir: + await self.emit(FileClick(self, dir_entry.path)) + else: + if not message.node.loaded: + await self.load_directory(message.node) + await message.node.expand() + else: + await message.node.toggle() + + +if __name__ == "__main__": + from textual import events + from textual.app import App + + class TreeApp(App): + async def on_mount(self, event: events.Mount) -> None: + await self.view.dock(DirectoryTree("/Users/willmcgugan/projects")) + + TreeApp.run(log="textual.log") diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 7aa1ff865..dd52e3d23 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -20,7 +20,7 @@ class Header(Widget): self, *, tall: bool = True, - style: StyleType = "white on blue", + style: StyleType = "white on dark_green", clock: bool = True, ) -> None: super().__init__() diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index ac3c1e403..0e2b4f185 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -78,6 +78,9 @@ class ScrollView(View): ) await self.layout.mount_all(self) + def home(self) -> None: + self.x = self.y = 0 + def scroll_up(self) -> None: self.target_y += 1.5 self.animate("y", self.target_y, easing="out_cubic", speed=80) @@ -168,20 +171,14 @@ class ScrollView(View): self.y = self.validate_y(self.y) self.vscroll.virtual_size = self.page.virtual_size.height self.vscroll.window_size = self.size.height - update = False + + assert isinstance(self.layout, GridLayout) + if self.layout.show_column( "vscroll", self.page.virtual_size.height > self.size.height ): - update = True - - self.hscroll.virtual_size = self.page.virtual_size.width - self.hscroll.window_size = self.size.width - + self.require_layout() if self.layout.show_row( "hscroll", self.page.virtual_size.width > self.size.width ): - update = True - - if update: - self.page.update() - self.layout.reset_update() + self.require_layout() diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py new file mode 100644 index 000000000..7212c3b09 --- /dev/null +++ b/src/textual/widgets/_tree_control.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import Any, Generic, NewType, TypeVar + +from rich.console import Console, ConsoleOptions, RenderableType + +from rich.style import Style, StyleType +from rich.styled import Styled +from rich.text import Text, TextType +from rich.tree import Tree +from rich.padding import Padding, PaddingDimensions + +from ..reactive import Reactive +from .._types import MessageTarget +from ..widget import Widget +from ..message import Message + + +NodeID = NewType("NodeID", int) + + +NodeDataType = TypeVar("NodeDataType") + + +class TreeNode(Generic[NodeDataType]): + def __init__( + self, + node_id: NodeID, + control: TreeControl, + tree: Tree, + label: TextType, + data: NodeDataType, + ) -> None: + self._node_id = node_id + self._control = control + self._tree = tree + self.label = label + self.data = data + self.loaded = False + self._expanded = False + self._empty = False + self._tree.expanded = False + + @property + def id(self) -> NodeID: + return self._node_id + + @property + def control(self) -> TreeControl: + return self._control + + @property + def empty(self) -> bool: + return self._empty + + @property + def expanded(self) -> bool: + return self._expanded + + @property + def tree(self) -> Tree: + return self._tree + + async def expand(self, expanded: bool = True) -> None: + self._expanded = expanded + self._tree.expanded = expanded + self._control.require_repaint() + + async def toggle(self) -> None: + await self.expand(not self._expanded) + + async def add(self, label: TextType, data: NodeDataType) -> None: + await self._control.add(self._node_id, label, data=data) + self._empty = False + + def __rich__(self) -> RenderableType: + return self._control.render_node(self) + + +class TreeClick(Generic[NodeDataType], Message, bubble=True): + def __init__(self, sender: MessageTarget, node: TreeNode[NodeDataType]) -> None: + self.node = node + super().__init__(sender) + + +class TreeControl(Generic[NodeDataType], Widget): + def __init__( + self, + label: TextType, + data: NodeDataType, + *, + name: str | None = None, + padding: PaddingDimensions = (1, 1), + ) -> None: + self.data = data + + self._node_id = NodeID(0) + self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {} + self._tree = Tree(label) + self.root: TreeNode[NodeDataType] = TreeNode( + self._node_id, self, self._tree, label, data + ) + self._tree.label = self.root + self.nodes[NodeID(self._node_id)] = self.root + self.padding = padding + super().__init__(name=name) + + hover_node: Reactive[NodeID | None] = Reactive(None) + + async def add( + self, + node_id: NodeID, + label: TextType, + data: NodeDataType, + ) -> None: + parent = self.nodes[node_id] + self._node_id = NodeID(self._node_id + 1) + child_tree = parent._tree.add(label) + child_node: TreeNode[NodeDataType] = TreeNode( + self._node_id, self, child_tree, label, data + ) + child_tree.label = child_node + self.nodes[self._node_id] = child_node + + self.require_repaint() + + def render(self) -> RenderableType: + return Padding(self._tree, self.padding) + + def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType: + meta = {"@click": f"click_label({node.id})", "tree_node": node.id} + label = Text(node.label) if isinstance(node.label, str) else node.label + if node.id == self.hover_node: + label.stylize("underline") + label.apply_meta(meta) + label.no_wrap = True + label.overflow = "ellipsis" + return label + + async def action_click_label(self, node_id: NodeID) -> None: + node = self.nodes[node_id] + await self.post_message(TreeClick(self, node)) + + async def on_mouse_move(self, event: events.MouseMove) -> None: + self.hover_node = event.style.meta.get("tree_node") + + +if __name__ == "__main__": + + from textual import events + from textual.app import App + + class TreeApp(App): + async def on_mount(self, event: events.Mount) -> None: + await self.view.dock(TreeControl("Tree Root", data="foo")) + + async def message_tree_click(self, message: TreeClick) -> None: + if message.node.empty: + await message.node.add("foo") + await message.node.add("bar") + await message.node.add("baz") + await message.node.expand() + else: + await message.node.toggle() + + TreeApp.run(log="textual.log") From 2d5b91618166d2c656ae60b5c0c924adcb752292 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 23 Jul 2021 12:08:59 +0100 Subject: [PATCH 09/36] document view --- src/textual/geometry.py | 7 ++++ src/textual/layout.py | 60 +++++++++++++++++++---------- src/textual/layouts/dock.py | 14 +++---- src/textual/layouts/grid.py | 14 +++---- src/textual/view.py | 31 ++++++++++++++- src/textual/views/_document_view.py | 3 ++ src/textual/widget.py | 5 --- 7 files changed, 94 insertions(+), 40 deletions(-) create mode 100644 src/textual/views/_document_view.py diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 1e3993f48..8ae4438c9 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -184,6 +184,13 @@ class Region(NamedTuple): return Region(x + ox, y + oy, width, height) return NotImplemented + def __sub__(self, other: Any) -> Region: + if isinstance(other, tuple): + ox, oy = other + x, y, width, height = self + return Region(x - ox, y - oy, width, height) + return NotImplemented + def overlaps(self, other: Region) -> bool: """Check if another region overlaps this region. diff --git a/src/textual/layout.py b/src/textual/layout.py index ea3def14e..7e27c9e7f 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -26,7 +26,7 @@ PY38 = sys.version_info >= (3, 8) if TYPE_CHECKING: - from .widget import Widget, WidgetID + from .widget import Widget from .view import View @@ -35,15 +35,26 @@ class NoWidget(Exception): @rich.repr.auto -class OrderedRegion(NamedTuple): +class RenderRegion(NamedTuple): region: Region order: tuple[int, int] + offset: Point + viewport: Region + + def translate(self, offset: Point) -> RenderRegion: + region, order, self_offset, viewport = self + return RenderRegion(region, order, self_offset + offset, viewport) def __rich_repr__(self) -> rich.repr.RichReprResult: yield "region", self.region yield "order", self.order +class OrderedRegion(NamedTuple): + region: Region + order: tuple[int, int] + + class ReflowResult(NamedTuple): """The result of a reflow operation. Describes the chances to widgets.""" @@ -76,7 +87,7 @@ class Layout(ABC): """Responsible for arranging Widgets in a view.""" def __init__(self) -> None: - self._layout_map: dict[Widget, OrderedRegion] = {} + self._layout_map: dict[Widget, RenderRegion] = {} self.width = 0 self.height = 0 self.renders: dict[Widget, tuple[Region, Lines]] = {} @@ -99,19 +110,24 @@ class Layout(ABC): self.renders.clear() self._layout_map.clear() - def reflow(self, width: int, height: int) -> ReflowResult: + def reflow(self, width: int, height: int, viewport: Region) -> ReflowResult: self.reset() - log(" REFLOW", self) - map = self.generate_map(width, height) + map = self.generate_map(width, height, Point(0, 0), viewport) self._require_update = False + map = { + widget: OrderedRegion(region + offset, order) + for widget, (region, order, offset, _viewport) in map.items() + } + # Filter out widgets that are off screen or zero area - screen_region = Region(0, 0, width, height) + log("VIEWPORT", viewport) + log(map) map = { widget: map_region for widget, map_region in map.items() - if map_region.region and screen_region.overlaps(map_region.region) + if map_region.region and viewport.overlaps(map_region.region) } old_widgets = set(self._layout_map.keys()) @@ -129,9 +145,11 @@ class Layout(ABC): new_renders = { widget: (region, self.renders[widget][1]) for widget, (region, _order) in map.items() - if widget in self.renders - and self.renders[widget][0].size == region.size - and not widget.check_repaint() + if ( + widget in self.renders + and self.renders[widget][0].size == region.size + and not widget.check_repaint() + ) } self.renders = new_renders @@ -152,20 +170,17 @@ class Layout(ABC): @abstractmethod def generate_map( - self, width: int, height: int, offset: Point = Point(0, 0) - ) -> dict[Widget, OrderedRegion]: + self, width: int, height: int, offset: Point, viewport: Region + ) -> dict[Widget, RenderRegion]: ... async def mount_all(self, view: "View") -> None: await view.mount(*self.get_widgets()) @property - def map(self) -> dict[Widget, OrderedRegion]: + def map(self) -> dict[Widget, RenderRegion]: return self._layout_map - # def __iter__(self) -> Iterator[tuple[Widget, Region]]: - # return self - def __iter__(self) -> Iterator[tuple[Widget, Region]]: layers = sorted( self._layout_map.items(), key=lambda item: item[1].order, reverse=True @@ -217,6 +232,13 @@ class Layout(ABC): @property def cuts(self) -> list[list[int]]: + """Get vertical cuts. + + A cut is every point on a line where a widget starts or ends. + + Returns: + list[list[int]]: A list of cuts for every line. + """ if self._cuts is not None: return self._cuts width = self.width @@ -302,10 +324,8 @@ class Layout(ABC): """Render a layout. Args: - layout_map (dict[WidgetID, MapRegion]): A layout map. console (Console): Console instance. - width (int): Width - height (int): Height + clip (Optional[Region]): Region to clip to. Returns: SegmentLines: A renderable diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index b2479700d..dafc691ad 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -7,7 +7,7 @@ from typing import Iterable, TYPE_CHECKING, Sequence from .._layout_resolve import layout_resolve from ..geometry import Region, Point -from ..layout import Layout, OrderedRegion +from ..layout import Layout, RenderRegion if sys.version_info >= (3, 8): from typing import Literal @@ -46,21 +46,21 @@ class DockLayout(Layout): yield from dock.widgets def generate_map( - self, width: int, height: int, offset: Point = Point(0, 0) - ) -> dict[Widget, OrderedRegion]: + self, width: int, height: int, offset: Point, viewport: Region + ) -> dict[Widget, RenderRegion]: from ..view import View - map: dict[Widget, OrderedRegion] = {} + map: dict[Widget, RenderRegion] = {} layout_region = Region(0, 0, width, height) layers: dict[int, Region] = defaultdict(lambda: layout_region) def add_widget(widget: Widget, region: Region, order: tuple[int, int]): - region = region + offset + widget.layout_offset - map[widget] = OrderedRegion(region, order) + region = region + widget.layout_offset + map[widget] = RenderRegion(region, order, offset, viewport) if isinstance(widget, View): sub_map = widget.layout.generate_map( - region.width, region.height, offset=region.origin + region.width, region.height, region.origin + offset, widget.viewport ) map.update(sub_map) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index afeb7ecfd..c2e6aaac8 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -11,7 +11,7 @@ from typing import Iterable, NamedTuple from .._layout_resolve import layout_resolve from ..geometry import Dimensions, Point, Region -from ..layout import Layout, OrderedRegion +from ..layout import Layout, RenderRegion from ..view import View from ..widget import Widget @@ -263,8 +263,8 @@ class GridLayout(Layout): return self.widgets.keys() def generate_map( - self, width: int, height: int, offset: Point = Point(0, 0) - ) -> dict[Widget, OrderedRegion]: + self, width: int, height: int, offset: Point, viewport: Region + ) -> dict[Widget, RenderRegion]: """Generate a map that associates widgets with their location on screen. Args: @@ -325,11 +325,11 @@ class GridLayout(Layout): return names, tracks, len(spans), max_size def add_widget(widget: Widget, region: Region, order: tuple[int, int]): - region = region + offset + widget.layout_offset - map[widget] = OrderedRegion(region, order) + region = region + widget.layout_offset + map[widget] = RenderRegion(region, order, offset, viewport) if isinstance(widget, View): sub_map = widget.layout.generate_map( - region.width, region.height, offset=region.origin + region.width, region.height, region.origin + offset, widget.viewport ) map.update(sub_map) @@ -364,7 +364,7 @@ class GridLayout(Layout): (col, row) for col, row in product(range(column_count), range(row_count)) } - map: dict[Widget, OrderedRegion] = {} + map: dict[Widget, RenderRegion] = {} order = 1 from_corners = Region.from_corners gutter = Point(self.column_gutter, self.row_gutter) diff --git a/src/textual/view.py b/src/textual/view.py index 24e324fc4..098aced73 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -46,10 +46,35 @@ class View(Widget): super().__init_subclass__(**kwargs) background: Reactive[str] = Reactive("") + offset_x: Reactive[int] = Reactive(0) + offset_y: Reactive[int] = Reactive(0) + virtual_width: Reactive[int | None] = Reactive(None) + virtual_height: Reactive[int | None] = Reactive(None) async def watch_background(self, value: str) -> None: self.layout.background = value + @property + def virtual_size(self) -> Dimensions: + virtual_width = self.virtual_width + virtual_height = self.virtual_height + return Dimensions( + (virtual_width if virtual_width is not None else self.size.width), + (virtual_height if virtual_height is not None else self.size.height), + ) + + @property + def offset(self) -> Point: + return Point(self.offset_x, self.offset_y) + + @property + def viewport(self) -> Region: + virtual_width = self.virtual_width + virtual_height = self.virtual_height + width = virtual_width if virtual_width is not None else self.size.width + height = virtual_height if virtual_height is not None else self.size.height + return Region(self.offset_x, self.offset_y, width, height) + def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: @@ -113,7 +138,11 @@ class View(Widget): return width, height = self.console.size - hidden, shown, resized = self.layout.reflow(width, height) + virtual_width, virtual_height = self.virtual_size + viewport = Region(self.offset_x, self.offset_y, width, height) + hidden, shown, resized = self.layout.reflow( + virtual_width, virtual_height, viewport + ) self.app.refresh() for widget in hidden: diff --git a/src/textual/views/_document_view.py b/src/textual/views/_document_view.py new file mode 100644 index 000000000..9fe5c6445 --- /dev/null +++ b/src/textual/views/_document_view.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from ..view import View diff --git a/src/textual/widget.py b/src/textual/widget.py index edea59421..58d4d9b6f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -30,9 +30,6 @@ if TYPE_CHECKING: from .app import App from .view import View - -WidgetID = NewType("WidgetID", int) - log = getLogger("rich") @@ -47,8 +44,6 @@ class Widget(MessagePump): Widget._counts.setdefault(class_name, 0) Widget._counts[class_name] += 1 _count = self._counts[class_name] - self.id: WidgetID = cast(WidgetID, Widget._id) - Widget._id += 1 self.name = name or f"{class_name}#{_count}" From 2a2936ff02406517e79081e5e213c9c0d71e3018 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jul 2021 15:50:49 +0100 Subject: [PATCH 10/36] Added console to generate_map --- src/textual/layout.py | 8 +++++--- src/textual/layouts/dock.py | 10 ++++++++-- src/textual/layouts/grid.py | 5 +++-- src/textual/view.py | 3 +-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/textual/layout.py b/src/textual/layout.py index 7e27c9e7f..8c8259564 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -110,10 +110,12 @@ class Layout(ABC): self.renders.clear() self._layout_map.clear() - def reflow(self, width: int, height: int, viewport: Region) -> ReflowResult: + def reflow( + self, console: Console, width: int, height: int, viewport: Region + ) -> ReflowResult: self.reset() - map = self.generate_map(width, height, Point(0, 0), viewport) + map = self.generate_map(console, width, height, Point(0, 0), viewport) self._require_update = False map = { @@ -170,7 +172,7 @@ class Layout(ABC): @abstractmethod def generate_map( - self, width: int, height: int, offset: Point, viewport: Region + self, console: Console, width: int, height: int, offset: Point, viewport: Region ) -> dict[Widget, RenderRegion]: ... diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index dafc691ad..43158eb32 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -5,6 +5,8 @@ from collections import defaultdict from dataclasses import dataclass from typing import Iterable, TYPE_CHECKING, Sequence +from rich.console import Console + from .._layout_resolve import layout_resolve from ..geometry import Region, Point from ..layout import Layout, RenderRegion @@ -46,7 +48,7 @@ class DockLayout(Layout): yield from dock.widgets def generate_map( - self, width: int, height: int, offset: Point, viewport: Region + self, console: Console, width: int, height: int, offset: Point, viewport: Region ) -> dict[Widget, RenderRegion]: from ..view import View @@ -60,7 +62,11 @@ class DockLayout(Layout): map[widget] = RenderRegion(region, order, offset, viewport) if isinstance(widget, View): sub_map = widget.layout.generate_map( - region.width, region.height, region.origin + offset, widget.viewport + console, + region.width, + region.height, + region.origin + offset, + widget.viewport, ) map.update(sub_map) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index c2e6aaac8..07af1d6ef 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -8,8 +8,9 @@ from itertools import cycle, product import sys from typing import Iterable, NamedTuple -from .._layout_resolve import layout_resolve +from rich.console import Console +from .._layout_resolve import layout_resolve from ..geometry import Dimensions, Point, Region from ..layout import Layout, RenderRegion from ..view import View @@ -263,7 +264,7 @@ class GridLayout(Layout): return self.widgets.keys() def generate_map( - self, width: int, height: int, offset: Point, viewport: Region + self, console: Console, width: int, height: int, offset: Point, viewport: Region ) -> dict[Widget, RenderRegion]: """Generate a map that associates widgets with their location on screen. diff --git a/src/textual/view.py b/src/textual/view.py index 098aced73..447d5c6a0 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -10,7 +10,6 @@ from rich.style import Style from . import events from . import log from .layout import Layout, NoWidget -from .layouts.dock import DockLayout from .geometry import Dimensions, Point, Region from .messages import UpdateMessage, LayoutMessage from .reactive import Reactive, watch @@ -141,7 +140,7 @@ class View(Widget): virtual_width, virtual_height = self.virtual_size viewport = Region(self.offset_x, self.offset_y, width, height) hidden, shown, resized = self.layout.reflow( - virtual_width, virtual_height, viewport + self.console, virtual_width, virtual_height, viewport ) self.app.refresh() From a5faf4a07e458aaf7bf4c2c6adbbb9e24001504d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jul 2021 16:59:44 +0100 Subject: [PATCH 11/36] remove viewport --- src/textual/layout.py | 22 +++++++++---- src/textual/layouts/dock.py | 12 +++---- src/textual/layouts/grid.py | 7 ++-- src/textual/layouts/vertical.py | 57 +++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 src/textual/layouts/vertical.py diff --git a/src/textual/layout.py b/src/textual/layout.py index 8c8259564..cc09c5497 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -19,7 +19,7 @@ from . import log from ._loop import loop_last from ._types import Lines -from .geometry import clamp, Region, Point +from .geometry import clamp, Region, Point, Dimensions PY38 = sys.version_info >= (3, 8) @@ -39,17 +39,22 @@ class RenderRegion(NamedTuple): region: Region order: tuple[int, int] offset: Point - viewport: Region def translate(self, offset: Point) -> RenderRegion: - region, order, self_offset, viewport = self - return RenderRegion(region, order, self_offset + offset, viewport) + region, order, self_offset = self + return RenderRegion(region, order, self_offset + offset) def __rich_repr__(self) -> rich.repr.RichReprResult: yield "region", self.region yield "order", self.order +@dataclass +class WidgetMap: + virtual_size: Dimensions + widgets: dict[Widget, RenderRegion] + + class OrderedRegion(NamedTuple): region: Region order: tuple[int, int] @@ -115,12 +120,12 @@ class Layout(ABC): ) -> ReflowResult: self.reset() - map = self.generate_map(console, width, height, Point(0, 0), viewport) + map = self.generate_map(console, Dimensions(width, height), Point(0, 0)) self._require_update = False map = { widget: OrderedRegion(region + offset, order) - for widget, (region, order, offset, _viewport) in map.items() + for widget, (region, order, offset) in map.items() } # Filter out widgets that are off screen or zero area @@ -172,7 +177,10 @@ class Layout(ABC): @abstractmethod def generate_map( - self, console: Console, width: int, height: int, offset: Point, viewport: Region + self, + console: Console, + size: Dimensions, + offset: Point, ) -> dict[Widget, RenderRegion]: ... diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 43158eb32..7c8d71106 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -8,7 +8,7 @@ from typing import Iterable, TYPE_CHECKING, Sequence from rich.console import Console from .._layout_resolve import layout_resolve -from ..geometry import Region, Point +from ..geometry import Region, Point, Dimensions from ..layout import Layout, RenderRegion if sys.version_info >= (3, 8): @@ -48,25 +48,23 @@ class DockLayout(Layout): yield from dock.widgets def generate_map( - self, console: Console, width: int, height: int, offset: Point, viewport: Region + self, console: Console, size: Dimensions, offset: Point ) -> dict[Widget, RenderRegion]: from ..view import View map: dict[Widget, RenderRegion] = {} - + width, height = size layout_region = Region(0, 0, width, height) layers: dict[int, Region] = defaultdict(lambda: layout_region) def add_widget(widget: Widget, region: Region, order: tuple[int, int]): region = region + widget.layout_offset - map[widget] = RenderRegion(region, order, offset, viewport) + map[widget] = RenderRegion(region, order, offset) if isinstance(widget, View): sub_map = widget.layout.generate_map( console, - region.width, - region.height, + Dimensions(region.width, region.height), region.origin + offset, - widget.viewport, ) map.update(sub_map) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 07af1d6ef..a0d480db7 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -264,7 +264,7 @@ class GridLayout(Layout): return self.widgets.keys() def generate_map( - self, console: Console, width: int, height: int, offset: Point, viewport: Region + self, console: Console, size: Dimensions, offset: Point ) -> dict[Widget, RenderRegion]: """Generate a map that associates widgets with their location on screen. @@ -276,6 +276,7 @@ class GridLayout(Layout): Returns: dict[Widget, OrderedRegion]: [description] """ + width, height = size def resolve( size: int, edges: list[GridOptions], gap: int, repeat: bool @@ -327,10 +328,10 @@ class GridLayout(Layout): def add_widget(widget: Widget, region: Region, order: tuple[int, int]): region = region + widget.layout_offset - map[widget] = RenderRegion(region, order, offset, viewport) + map[widget] = RenderRegion(region, order, offset) if isinstance(widget, View): sub_map = widget.layout.generate_map( - region.width, region.height, region.origin + offset, widget.viewport + region.width, region.height, region.origin + offset ) map.update(sub_map) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py new file mode 100644 index 000000000..f94f9e563 --- /dev/null +++ b/src/textual/layouts/vertical.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from rich.console import Console + + +from ..geometry import Point, Region, Dimensions +from ..layout import Layout, RenderRegion, WidgetMap +from ..widget import Widget +from ..view import View + + +class VerticalLayout(Layout): + def __init__(self, gutter: tuple[int, int] = (0, 1)): + self.gutter = gutter or (0, 0) + self._widgets: list[Widget] = [] + super().__init__() + + def add(self, widget: Widget) -> None: + self._widgets.append(widget) + + def generate_map( + self, console: Console, size: Dimensions, offset: Point + ) -> WidgetMap: + width, height = size + gutter_width, gutter_height = self.gutter + render_width = width - gutter_width * 2 + x = gutter_width + y = gutter_height + map: dict[Widget, RenderRegion] = {} + + def add_widget(widget: Widget, region: Region): + order = (0, 0) + region = region + widget.layout_offset + map[widget] = RenderRegion(region, order, offset) + if isinstance(widget, View): + sub_map = widget.layout.generate_map( + console, + Dimensions(region.width, region.height), + region.origin + offset, + ) + map.update(sub_map) + + for widget in self._widgets: + region_lines = self.renders.get(widget) + if region_lines is None: + renderable = widget.render() + lines = console.render_lines( + renderable, console.options.update_width(render_width) + ) + region = Region(x, y, render_width, len(lines)) + add_widget(widget, region) + else: + region, lines = region_lines + add_widget(widget, Region(x, y, region.width, region.height)) + y += region.height + gutter_height + widget_map = WidgetMap(Dimensions(width, y), map) + return widget_map From 534f7b4dc1db8e76054cb1a28b441bf8e80f6eaf Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Jul 2021 14:23:28 +0100 Subject: [PATCH 12/36] layout_map refactor --- examples/animation.py | 5 +- examples/code_viewer.py | 1 + examples/grid.py | 17 +++- src/textual/app.py | 2 +- src/textual/geometry.py | 67 +++++++++----- src/textual/layout.py | 158 +++++++++++++++----------------- src/textual/layout_map.py | 61 ++++++++++++ src/textual/layouts/dock.py | 86 ++++++++--------- src/textual/layouts/grid.py | 26 +++--- src/textual/layouts/vertical.py | 5 +- src/textual/view.py | 8 +- src/textual/widget.py | 4 +- tests/test_geometry.py | 142 ++++++++++++++++++++++++++++ 13 files changed, 405 insertions(+), 177 deletions(-) create mode 100644 src/textual/layout_map.py diff --git a/examples/animation.py b/examples/animation.py index 18b5ce2ff..4c9df9e8e 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -26,11 +26,12 @@ class SmoothApp(App): """Build layout here.""" footer = Footer() self.bar = Placeholder(name="left") - self.bar.layout_offset_x = -40 await self.view.dock(footer, edge="bottom") await self.view.dock(Placeholder(), Placeholder(), edge="top") await self.view.dock(self.bar, edge="left", size=40, z=1) + self.bar.layout_offset_x = -40 -SmoothApp.run() + +SmoothApp.run(log="textual.log") diff --git a/examples/code_viewer.py b/examples/code_viewer.py index 6fbdbafbf..e4c3a4c42 100644 --- a/examples/code_viewer.py +++ b/examples/code_viewer.py @@ -42,6 +42,7 @@ class MyApp(App): ) self.app.sub_title = os.path.basename(message.path) await self.body.update(syntax) + # self.body.layout_offset_y = -5 self.body.home() diff --git a/examples/grid.py b/examples/grid.py index c48eb0c94..4bb48929f 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -9,7 +9,8 @@ class GridTest(App): async def on_mount(self, event: events.Mount) -> None: - grid = await self.view.dock_grid() + grid = await self.view.dock_grid(edge="left", size=70, name="left") + left = self.view["left"] grid.add_column(fraction=1, name="left", min_size=20) grid.add_column(size=30, name="center") @@ -26,11 +27,17 @@ class GridTest(App): area4="right,top-start|middle-end", ) + def make_placeholder(name: str) -> Placeholder: + p = Placeholder(name=name) + p.layout_offset_x = 10 + p.layout_offset_y = 0 + return p + grid.place( - area1=Placeholder(name="area1"), - area2=Placeholder(name="area2"), - area3=Placeholder(name="area3"), - area4=Placeholder(name="area4"), + area1=make_placeholder(name="area1"), + area2=make_placeholder(name="area2"), + area3=make_placeholder(name="area3"), + area4=make_placeholder(name="area4"), ) diff --git a/src/textual/app.py b/src/textual/app.py index cdb89c8ad..ef9dbb84a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -84,7 +84,7 @@ class App(MessagePump): driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None. title (str, optional): Title of the application. Defaults to "Textual Application". """ - self.console = console or get_console() + self.console = console or Console() self.error_console = Console(stderr=True) self._screen = screen self.driver_class = driver_class or LinuxDriver diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 8ae4438c9..94c97c2ba 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -76,8 +76,19 @@ class Dimensions(NamedTuple): @property def area(self) -> int: + """Get the area of the dimensions. + + Returns: + int: Area in cells. + """ return self.width * self.height + @property + def region(self) -> Region: + """Get a region of the same size.""" + width, height = self + return Region(0, 0, width, height) + def contains(self, x: int, y: int) -> bool: """Check if a point is in the region. @@ -138,8 +149,23 @@ class Region(NamedTuple): """ return cls(x1, y1, x2 - x1, y2 - y1) + @classmethod + def from_origin(cls, origin: tuple[int, int], size: tuple[int, int]) -> Region: + """Create a region from origin and size. + + Args: + origin (Point): [description] + size (tuple[int, int]): [description] + + Returns: + Region: [description] + """ + x, y = origin + width, height = size + return Region(x, y, width, height) + def __bool__(self) -> bool: - return self.width != 0 and self.height != 0 + return bool(self.width and self.height) @property def area(self) -> int: @@ -151,17 +177,6 @@ class Region(NamedTuple): """Get the start point of the region.""" return Point(self.x, self.y) - @property - def limit(self) -> Point: - x, y, width, height = self - return Point(x + width, y + height) - - @property - def limit_inclusive(self) -> Point: - """Get the end point of the region.""" - x, y, width, height = self - return Point(x + width - 1, y + height - 1) - @property def size(self) -> Dimensions: """Get the size of the region.""" @@ -251,7 +266,7 @@ class Region(NamedTuple): x2 >= ox2 >= x1 and y2 >= oy2 >= y1 ) - def translate(self, translate_x: int, translate_y: int) -> Region: + def translate(self, x: int = 0, y: int = 0) -> Region: """Move the origin of the Region. Args: @@ -262,8 +277,8 @@ class Region(NamedTuple): Region: A new region shifted by x, y """ - x, y, width, height = self - return Region(x + translate_x, y + translate_y, width, height) + self_x, self_y, width, height = self + return Region(self_x + x, self_y + y, width, height) def __contains__(self, other: Any) -> bool: """Check if a point is in this region.""" @@ -287,16 +302,17 @@ class Region(NamedTuple): """ x1, y1, x2, y2 = self.corners + _clamp = clamp new_region = Region.from_corners( - clamp(x1, 0, width), - clamp(y1, 0, height), - clamp(x2, 0, width), - clamp(y2, 0, height), + _clamp(x1, 0, width), + _clamp(y1, 0, height), + _clamp(x2, 0, width), + _clamp(y2, 0, height), ) return new_region - def clip_region(self, region: Region) -> Region: - """Clip this region to fit within another region. + def intersection(self, region: Region) -> Region: + """Get that covers both regions. Args: region ([type]): A region that overlaps this region. @@ -307,10 +323,11 @@ class Region(NamedTuple): x1, y1, x2, y2 = self.corners cx1, cy1, cx2, cy2 = region.corners + _clamp = clamp new_region = Region.from_corners( - clamp(x1, cx1, cx2), - clamp(y1, cy1, cy2), - clamp(x2, cx2, cx2), - clamp(y2, cy2, cy2), + _clamp(x1, cx1, cx2), + _clamp(y1, cy1, cy2), + _clamp(x2, cx1, cx2), + _clamp(y2, cy1, cy2), ) return new_region diff --git a/src/textual/layout.py b/src/textual/layout.py index cc09c5497..bb01da481 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -17,6 +17,7 @@ from rich.style import Style from . import log from ._loop import loop_last +from .layout_map import LayoutMap from ._types import Lines from .geometry import clamp, Region, Point, Dimensions @@ -34,27 +35,6 @@ class NoWidget(Exception): pass -@rich.repr.auto -class RenderRegion(NamedTuple): - region: Region - order: tuple[int, int] - offset: Point - - def translate(self, offset: Point) -> RenderRegion: - region, order, self_offset = self - return RenderRegion(region, order, self_offset + offset) - - def __rich_repr__(self) -> rich.repr.RichReprResult: - yield "region", self.region - yield "order", self.order - - -@dataclass -class WidgetMap: - virtual_size: Dimensions - widgets: dict[Widget, RenderRegion] - - class OrderedRegion(NamedTuple): region: Region order: tuple[int, int] @@ -92,10 +72,10 @@ class Layout(ABC): """Responsible for arranging Widgets in a view.""" def __init__(self) -> None: - self._layout_map: dict[Widget, RenderRegion] = {} + self._layout_map: LayoutMap | None = None self.width = 0 self.height = 0 - self.renders: dict[Widget, tuple[Region, Lines]] = {} + self.renders: dict[Widget, tuple[Region, Region, Lines]] = {} self._cuts: list[list[int]] | None = None self._require_update: bool = True self.background = "" @@ -113,31 +93,33 @@ class Layout(ABC): self._cuts = None if self._require_update: self.renders.clear() - self._layout_map.clear() + self._layout_map = None def reflow( self, console: Console, width: int, height: int, viewport: Region ) -> ReflowResult: self.reset() - map = self.generate_map(console, Dimensions(width, height), Point(0, 0)) + map = self.generate_map( + console, Dimensions(width, height), Region(0, 0, width, height) + ) self._require_update = False - map = { - widget: OrderedRegion(region + offset, order) - for widget, (region, order, offset) in map.items() - } + # log(map.widgets) + # map = { + # widget: OrderedRegion(region + offset, order) + # for widget, (region, order, offset) in map.items() + # } # Filter out widgets that are off screen or zero area - log("VIEWPORT", viewport) - log(map) - map = { - widget: map_region - for widget, map_region in map.items() - if map_region.region and viewport.overlaps(map_region.region) - } - old_widgets = set(self._layout_map.keys()) + # map = { + # widget: map_region + # for widget, map_region in map.items() + # if map_region.region and viewport.overlaps(map_region.region) + # } + + old_widgets = set() if self.map is None else set(self.map.keys()) new_widgets = set(map.keys()) # Newly visible widgets shown_widgets = new_widgets - old_widgets @@ -150,8 +132,8 @@ class Layout(ABC): # Copy renders if the size hasn't changed new_renders = { - widget: (region, self.renders[widget][1]) - for widget, (region, _order) in map.items() + widget: (region, clip, self.renders[widget][2]) + for widget, (region, _order, clip) in map.items() if ( widget in self.renders and self.renders[widget][0].size == region.size @@ -163,7 +145,7 @@ class Layout(ABC): # Widgets with changed size resized_widgets = { widget - for widget, (region, _order) in map.items() + for widget, (region, *_) in map.items() if widget in old_widgets and widget.size != region.size } @@ -177,35 +159,34 @@ class Layout(ABC): @abstractmethod def generate_map( - self, - console: Console, - size: Dimensions, - offset: Point, - ) -> dict[Widget, RenderRegion]: + self, console: Console, size: Dimensions, viewport: Region + ) -> LayoutMap: ... async def mount_all(self, view: "View") -> None: await view.mount(*self.get_widgets()) @property - def map(self) -> dict[Widget, RenderRegion]: + def map(self) -> LayoutMap | None: return self._layout_map def __iter__(self) -> Iterator[tuple[Widget, Region]]: - layers = sorted( - self._layout_map.items(), key=lambda item: item[1].order, reverse=True - ) - for widget, (region, _) in layers: - yield widget, region + if self.map is not None: + layers = sorted( + self.map.widgets.items(), key=lambda item: item[1].order, reverse=True + ) + for widget, (region, order, clip) in layers: + yield widget, region.intersection(clip) def __reversed__(self) -> Iterable[tuple[Widget, Region]]: - layers = sorted(self._layout_map.items(), key=lambda item: item[1].order) - for widget, (region, _) in layers: - yield widget, region + if self.map is not None: + layers = sorted(self.map.items(), key=lambda item: item[1].order) + for widget, (region, _order, clip) in layers: + yield widget, region.intersection(clip) def get_offset(self, widget: Widget) -> Point: try: - return self._layout_map[widget].region.origin + return self.map[widget].region.origin except KeyError: raise NoWidget("Widget is not in layout") @@ -221,7 +202,9 @@ class Layout(ABC): widget, region = self.get_widget_at(x, y) except NoWidget: return Style.null() - _region, lines = self.renders[widget] + if widget not in self.renders: + return Style.null() + _region, clip, lines = self.renders[widget] x -= region.x y -= region.y line = lines[y] @@ -234,7 +217,7 @@ class Layout(ABC): def get_widget_region(self, widget: Widget) -> Region: try: - region, _ = self._layout_map[widget] + region, *_ = self.map[widget] except KeyError: raise NoWidget("Widget is not in layout") else: @@ -256,28 +239,35 @@ class Layout(ABC): screen_region = Region(0, 0, width, height) cuts_sets = [{0, width} for _ in range(height)] - for region, order in self._layout_map.values(): - region = region.clip(width, height) - if region and (region in screen_region): # type: ignore - for y in range(region.y, region.y + region.height): - cuts_sets[y].update({region.x, region.x + region.width}) + if self.map is not None: + for region, order, clip in self.map.values(): + region = region.intersection(clip) + if region and (region in screen_region): # type: ignore + for y in range(region.y, region.y + region.height): + cuts_sets[y].update({region.x, region.x + region.width}) # Sort the cuts for each line self._cuts = [sorted(cut_set) for cut_set in cuts_sets] return self._cuts - def _get_renders(self, console: Console) -> Iterable[tuple[Region, Lines]]: + def _get_renders(self, console: Console) -> Iterable[tuple[Region, Region, Lines]]: _rich_traceback_guard = True width = self.width height = self.height screen_region = Region(0, 0, width, height) - layout_map = self._layout_map + layout_map = self.map - widget_regions = sorted( - ((widget, region, order) for widget, (region, order) in layout_map.items()), - key=itemgetter(2), - reverse=True, - ) + if layout_map: + widget_regions = sorted( + ( + (widget, region, order, clip) + for widget, (region, order, clip) in layout_map.items() + ), + key=itemgetter(2), + reverse=True, + ) + else: + widget_regions = [] def render(widget: Widget, width: int, height: int) -> Lines: lines = console.render_lines( @@ -285,7 +275,7 @@ class Layout(ABC): ) return lines - for widget, region, _order in widget_regions: + for widget, region, _order, clip in widget_regions: if not widget.is_visual: continue @@ -295,23 +285,22 @@ class Layout(ABC): continue lines = render(widget, region.width, region.height) - if region in screen_region: - self.renders[widget] = (region, lines) - yield region, lines - elif screen_region.overlaps(region): - new_region = region.clip(width, height) + if region in clip: + self.renders[widget] = (region, clip, lines) + yield region, clip, lines + elif clip.overlaps(region): + new_region = region.intersection(clip) delta_x = new_region.x - region.x delta_y = new_region.y - region.y - region = new_region + self.renders[widget] = (region, clip, lines) + splits = [delta_x, delta_x + new_region.width] - splits = [delta_x, delta_x + region.width] divide = Segment.divide lines = [ list(divide(line, splits))[1] - for line in lines[delta_y : delta_y + region.height] + for line in lines[delta_y : delta_y + new_region.height] ] - self.renders[widget] = (region, lines) - yield region, lines + yield region, clip, lines @classmethod def _assemble_chops( @@ -361,7 +350,10 @@ class Layout(ABC): ] # Go through all the renders in reverse order and fill buckets with no render renders = self._get_renders(console) - for region, lines in chain(renders, [(screen, background_render)]): + for region, clip, lines in chain( + renders, [(screen, screen, background_render)] + ): + # region = region.intersection(clip) for y, line in enumerate(lines, region.y): if clip_y > y > clip_y2: continue @@ -391,12 +383,12 @@ class Layout(ABC): if widget not in self.renders: return None - region, lines = self.renders[widget] + region, clip, lines = self.renders[widget] new_lines = console.render_lines( widget, console.options.update_dimensions(region.width, region.height) ) - self.renders[widget] = (region, new_lines) + self.renders[widget] = (region, clip, new_lines) update_lines = self.render(console, region).lines return LayoutUpdate(update_lines, region.x, region.y) diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py new file mode 100644 index 000000000..33ab2e26e --- /dev/null +++ b/src/textual/layout_map.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from rich.console import Console + +from typing import ItemsView, KeysView, ValuesView, NamedTuple + +from .geometry import Region, Dimensions + +from .widget import Widget + + +class Order(NamedTuple): + layer: int + z: int + + +class RenderRegion(NamedTuple): + region: Region + order: tuple[int, ...] + clip: Region + + +class LayoutMap: + def __init__(self, size: Dimensions) -> None: + self.size = size + self.widgets: dict[Widget, RenderRegion] = {} + + def __getitem__(self, widget: Widget) -> RenderRegion: + return self.widgets[widget] + + def items(self) -> ItemsView: + return self.widgets.items() + + def keys(self) -> KeysView: + return self.widgets.keys() + + def values(self) -> ValuesView: + return self.widgets.values() + + def clear(self) -> None: + self.widgets.clear() + + def add_widget( + self, + console: Console, + widget: Widget, + region: Region, + order: tuple[int, ...], + clip: Region, + ) -> None: + from .view import View + + region += widget.layout_offset + self.widgets[widget] = RenderRegion(region, order, clip) + + if isinstance(widget, View): + sub_map = widget.layout.generate_map(console, region.size, region) + for widget, (sub_region, sub_order, sub_clip) in sub_map.items(): + sub_region += region.origin + sub_clip = sub_clip.intersection(clip) + self.add_widget(console, widget, sub_region, sub_order, sub_clip) diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 7c8d71106..330f47e43 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -9,7 +9,8 @@ from rich.console import Console from .._layout_resolve import layout_resolve from ..geometry import Region, Point, Dimensions -from ..layout import Layout, RenderRegion +from ..layout import Layout +from ..layout_map import LayoutMap, Order if sys.version_info >= (3, 8): from typing import Literal @@ -48,25 +49,16 @@ class DockLayout(Layout): yield from dock.widgets def generate_map( - self, console: Console, size: Dimensions, offset: Point - ) -> dict[Widget, RenderRegion]: - from ..view import View + self, console: Console, size: Dimensions, viewport: Region + ) -> LayoutMap: - map: dict[Widget, RenderRegion] = {} + map: LayoutMap = LayoutMap(size) width, height = size layout_region = Region(0, 0, width, height) layers: dict[int, Region] = defaultdict(lambda: layout_region) - def add_widget(widget: Widget, region: Region, order: tuple[int, int]): - region = region + widget.layout_offset - map[widget] = RenderRegion(region, order, offset) - if isinstance(widget, View): - sub_map = widget.layout.generate_map( - console, - Dimensions(region.width, region.height), - region.origin + offset, - ) - map.update(sub_map) + def add_widget(widget: Widget, region: Region, order: tuple[int, ...]): + map.add_widget(console, widget, region, order, viewport) for index, dock in enumerate(self.docks): dock_options = [ @@ -88,16 +80,16 @@ class DockLayout(Layout): render_y = y remaining = region.height total = 0 - for widget, size in zip(dock.widgets, sizes): + for widget, layout_size in zip(dock.widgets, sizes): if not widget.visible: continue - size = min(remaining, size) - if not size: + layout_size = min(remaining, layout_size) + if not layout_size: break - total += size - add_widget(widget, Region(x, render_y, width, size), order) - render_y += size - remaining = max(0, remaining - size) + total += layout_size + add_widget(widget, Region(x, render_y, width, layout_size), order) + render_y += layout_size + remaining = max(0, remaining - layout_size) region = Region(x, y + total, width, height - total) elif dock.edge == "bottom": @@ -105,16 +97,20 @@ class DockLayout(Layout): render_y = y + height remaining = region.height total = 0 - for widget, size in zip(dock.widgets, sizes): + for widget, layout_size in zip(dock.widgets, sizes): if not widget.visible: continue - size = min(remaining, size) - if not size: + layout_size = min(remaining, layout_size) + if not layout_size: break - total += size - add_widget(widget, Region(x, render_y - size, width, size), order) - render_y -= size - remaining = max(0, remaining - size) + total += layout_size + add_widget( + widget, + Region(x, render_y - layout_size, width, layout_size), + order, + ) + render_y -= layout_size + remaining = max(0, remaining - layout_size) region = Region(x, y, width, height - total) elif dock.edge == "left": @@ -122,16 +118,16 @@ class DockLayout(Layout): render_x = x remaining = region.width total = 0 - for widget, size in zip(dock.widgets, sizes): + for widget, layout_size in zip(dock.widgets, sizes): if not widget.visible: continue - size = min(remaining, size) - if not size: + layout_size = min(remaining, layout_size) + if not layout_size: break - total += size - add_widget(widget, Region(render_x, y, size, height), order) - render_x += size - remaining = max(0, remaining - size) + total += layout_size + add_widget(widget, Region(render_x, y, layout_size, height), order) + render_x += layout_size + remaining = max(0, remaining - layout_size) region = Region(x + total, y, width - total, height) elif dock.edge == "right": @@ -139,16 +135,20 @@ class DockLayout(Layout): render_x = x + width remaining = region.width total = 0 - for widget, size in zip(dock.widgets, sizes): + for widget, layout_size in zip(dock.widgets, sizes): if not widget.visible: continue - size = min(remaining, size) - if not size: + layout_size = min(remaining, layout_size) + if not layout_size: break - total += size - add_widget(widget, Region(render_x - size, y, size, height), order) - render_x -= size - remaining = max(0, remaining - size) + total += layout_size + add_widget( + widget, + Region(render_x - layout_size, y, layout_size, height), + order, + ) + render_x -= layout_size + remaining = max(0, remaining - layout_size) region = Region(x, y, width - total, height) layers[dock.z] = region diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index a0d480db7..48363a095 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -12,8 +12,8 @@ from rich.console import Console from .._layout_resolve import layout_resolve from ..geometry import Dimensions, Point, Region -from ..layout import Layout, RenderRegion -from ..view import View +from ..layout import Layout +from ..layout_map import LayoutMap from ..widget import Widget if sys.version_info >= (3, 8): @@ -264,8 +264,8 @@ class GridLayout(Layout): return self.widgets.keys() def generate_map( - self, console: Console, size: Dimensions, offset: Point - ) -> dict[Widget, RenderRegion]: + self, console: Console, size: Dimensions, viewport: Region + ) -> LayoutMap: """Generate a map that associates widgets with their location on screen. Args: @@ -276,6 +276,7 @@ class GridLayout(Layout): Returns: dict[Widget, OrderedRegion]: [description] """ + map: LayoutMap = LayoutMap(size) width, height = size def resolve( @@ -327,13 +328,14 @@ class GridLayout(Layout): return names, tracks, len(spans), max_size def add_widget(widget: Widget, region: Region, order: tuple[int, int]): - region = region + widget.layout_offset - map[widget] = RenderRegion(region, order, offset) - if isinstance(widget, View): - sub_map = widget.layout.generate_map( - region.width, region.height, region.origin + offset - ) - map.update(sub_map) + map.add_widget(console, widget, region, order, viewport) + # region = region + widget.layout_offset + # map[widget] = RenderRegion(region, order, offset) + # if isinstance(widget, View): + # sub_map = widget.layout.generate_map( + # region.width, region.height, region.origin + offset + # ) + # map.update(sub_map) container = Dimensions( width - self.column_gutter * 2, height - self.row_gutter * 2 @@ -365,8 +367,6 @@ class GridLayout(Layout): free_slots = { (col, row) for col, row in product(range(column_count), range(row_count)) } - - map: dict[Widget, RenderRegion] = {} order = 1 from_corners = Region.from_corners gutter = Point(self.column_gutter, self.row_gutter) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index f94f9e563..2847b2216 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -4,7 +4,7 @@ from rich.console import Console from ..geometry import Point, Region, Dimensions -from ..layout import Layout, RenderRegion, WidgetMap +from ..layout import Layout from ..widget import Widget from ..view import View @@ -19,8 +19,9 @@ class VerticalLayout(Layout): self._widgets.append(widget) def generate_map( - self, console: Console, size: Dimensions, offset: Point + self, console: Console, size: Dimensions, viewport: Region ) -> WidgetMap: + offset = viewport.origin width, height = size gutter_width, gutter_height = self.gutter render_width = width - gutter_width * 2 diff --git a/src/textual/view.py b/src/textual/view.py index 447d5c6a0..e01245485 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -62,6 +62,12 @@ class View(Widget): (virtual_height if virtual_height is not None else self.size.height), ) + @virtual_size.setter + def virtual_size(self, size: tuple[int, int]) -> None: + width, height = size + self.virtual_width = width + self.virtual_height = height + @property def offset(self) -> Point: return Point(self.offset_x, self.offset_y) @@ -133,7 +139,7 @@ class View(Widget): async def refresh_layout(self) -> None: await self.layout.mount_all(self) - if not self.size or not self.is_root_view: + if not self.size: return width, height = self.console.size diff --git a/src/textual/widget.py b/src/textual/widget.py index 58d4d9b6f..d3ceed38e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -61,8 +61,8 @@ class Widget(MessagePump): layout_size: Reactive[int | None] = Reactive(None, layout=True) layout_fraction: Reactive[int] = Reactive(1, layout=True) layout_min_size: Reactive[int] = Reactive(1, layout=True) - layout_offset_x: Reactive[int] = Reactive(0, layout=True) - layout_offset_y: Reactive[int] = Reactive(0, layout=True) + layout_offset_x: Reactive[float] = Reactive(0.0, layout=True) + layout_offset_y: Reactive[float] = Reactive(0.0, layout=True) def validate_layout_offset_x(self, value) -> int: return int(value) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index a4a19a26c..cef706f1f 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -3,6 +3,57 @@ import pytest from textual.geometry import clamp, Point, Dimensions, Region +def test_dimensions_region(): + assert Dimensions(30, 40).region == Region(0, 0, 30, 40) + + +def test_dimensions_contains(): + assert Dimensions(10, 10).contains(5, 5) + assert Dimensions(10, 10).contains(9, 9) + assert Dimensions(10, 10).contains(0, 0) + assert not Dimensions(10, 10).contains(10, 9) + assert not Dimensions(10, 10).contains(9, 10) + assert not Dimensions(10, 10).contains(-1, 0) + assert not Dimensions(10, 10).contains(0, -1) + + +def test_dimensions_contains_point(): + assert Dimensions(10, 10).contains_point(Point(5, 5)) + assert Dimensions(10, 10).contains_point(Point(9, 9)) + assert Dimensions(10, 10).contains_point(Point(0, 0)) + assert not Dimensions(10, 10).contains_point(Point(10, 9)) + assert not Dimensions(10, 10).contains_point(Point(9, 10)) + assert not Dimensions(10, 10).contains_point(Point(-1, 0)) + assert not Dimensions(10, 10).contains_point(Point(0, -1)) + + +def test_dimensions_contains_special(): + with pytest.raises(TypeError): + (1, 2, 3) in Dimensions(10, 10) + + assert (5, 5) in Dimensions(10, 10) + assert (9, 9) in Dimensions(10, 10) + assert (0, 0) in Dimensions(10, 10) + assert (10, 9) not in Dimensions(10, 10) + assert (9, 10) not in Dimensions(10, 10) + assert (-1, 0) not in Dimensions(10, 10) + assert (0, -1) not in Dimensions(10, 10) + + +def test_dimensions_bool(): + assert Dimensions(1, 1) + assert Dimensions(3, 4) + assert not Dimensions(0, 1) + assert not Dimensions(1, 0) + + +def test_dimensions_area(): + assert Dimensions(0, 0).area == 0 + assert Dimensions(1, 0).area == 0 + assert Dimensions(1, 1).area == 1 + assert Dimensions(4, 5).area == 20 + + def test_clamp(): assert clamp(5, 0, 10) == 5 assert clamp(-1, 0, 10) == 0 @@ -34,3 +85,94 @@ def test_point_blend(): assert Point(1, 2).blend(Point(3, 4), 0) == Point(1, 2) assert Point(1, 2).blend(Point(3, 4), 1) == Point(3, 4) assert Point(1, 2).blend(Point(3, 4), 0.5) == Point(2, 3) + + +def test_region_from_origin(): + assert Region.from_origin(Point(3, 4), (5, 6)) == Region(3, 4, 5, 6) + + +def test_region_area(): + assert Region(3, 4, 0, 0).area == 0 + assert Region(3, 4, 5, 6).area == 30 + + +def test_region_size(): + assert isinstance(Region(3, 4, 5, 6).size, Dimensions) + assert Region(3, 4, 5, 6).size == Dimensions(5, 6) + + +def test_region_origin(): + assert Region(1, 2, 3, 4).origin == Point(1, 2) + + +def test_region_add(): + assert Region(1, 2, 3, 4) + (10, 20) == Region(11, 22, 3, 4) + with pytest.raises(TypeError): + Region(1, 2, 3, 4) + "foo" + + +def test_region_sub(): + assert Region(11, 22, 3, 4) - (10, 20) == Region(1, 2, 3, 4) + with pytest.raises(TypeError): + Region(1, 2, 3, 4) - "foo" + + +def test_region_overlaps(): + assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20)) + assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20)) + + assert not Region(10, 10, 5, 5).overlaps(Region(0, 0, 50, 10)) + assert Region(10, 10, 5, 5).overlaps(Region(0, 0, 50, 11)) + assert not Region(10, 10, 5, 5).overlaps(Region(0, 15, 50, 10)) + assert Region(10, 10, 5, 5).overlaps(Region(0, 14, 50, 10)) + + +def test_region_contains(): + assert Region(10, 10, 20, 30).contains(10, 10) + assert Region(10, 10, 20, 30).contains(29, 39) + assert not Region(10, 10, 20, 30).contains(30, 40) + + +def test_region_contains_point(): + assert Region(10, 10, 20, 30).contains_point((10, 10)) + assert Region(10, 10, 20, 30).contains_point((29, 39)) + assert not Region(10, 10, 20, 30).contains_point((30, 40)) + with pytest.raises(TypeError): + Region(10, 10, 20, 30).contains_point((1, 2, 3)) + + +def test_region_contains_region(): + assert Region(10, 10, 20, 30).contains_region(Region(10, 10, 5, 5)) + assert not Region(10, 10, 20, 30).contains_region(Region(10, 9, 5, 5)) + assert not Region(10, 10, 20, 30).contains_region(Region(9, 10, 5, 5)) + assert Region(10, 10, 20, 30).contains_region(Region(10, 10, 20, 30)) + assert not Region(10, 10, 20, 30).contains_region(Region(10, 10, 21, 30)) + assert not Region(10, 10, 20, 30).contains_region(Region(10, 10, 20, 31)) + + +def test_region_translate(): + assert Region(1, 2, 3, 4).translate(10, 20) == Region(11, 22, 3, 4) + assert Region(1, 2, 3, 4).translate(y=20) == Region(1, 22, 3, 4) + + +def test_region_contains_special(): + assert (10, 10) in Region(10, 10, 20, 30) + assert (9, 10) not in Region(10, 10, 20, 30) + assert Region(10, 10, 5, 5) in Region(10, 10, 20, 30) + assert Region(5, 5, 5, 5) not in Region(10, 10, 20, 30) + assert "foo" not in Region(0, 0, 10, 10) + + +def test_clip(): + assert Region(10, 10, 20, 30).clip(20, 25) == Region(10, 10, 10, 15) + + +def test_region_intersection(): + assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region( + 10, 10, 10, 10 + ) + assert Region(10, 10, 30, 20).intersection(Region(20, 15, 60, 40)) == Region( + 20, 15, 20, 15 + ) + + assert not Region(10, 10, 20, 30).intersection(Region(50, 50, 100, 200)) From 06ebb742428d0d0e0426779866bb09f47d37bd30 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Jul 2021 18:57:37 +0100 Subject: [PATCH 13/36] emol --- examples/code_viewer.py | 1 - src/textual/geometry.py | 17 ++++++++ src/textual/layout_map.py | 12 +++--- src/textual/layouts/dock.py | 4 +- src/textual/layouts/vertical.py | 50 ++++++++++++----------- src/textual/page.py | 16 +++----- src/textual/view.py | 62 ++++++++++++++++------------- src/textual/views/__init__.py | 1 + src/textual/views/_window_view.py | 17 ++++++++ src/textual/widgets/_scroll_view.py | 14 +++---- tests/test_geometry.py | 4 ++ 11 files changed, 120 insertions(+), 78 deletions(-) create mode 100644 src/textual/views/_window_view.py diff --git a/examples/code_viewer.py b/examples/code_viewer.py index e4c3a4c42..6fbdbafbf 100644 --- a/examples/code_viewer.py +++ b/examples/code_viewer.py @@ -42,7 +42,6 @@ class MyApp(App): ) self.app.sub_title = os.path.basename(message.path) await self.body.update(syntax) - # self.body.layout_offset_y = -5 self.body.home() diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 94c97c2ba..1d55b7e54 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -331,3 +331,20 @@ class Region(NamedTuple): _clamp(y2, cy1, cy2), ) return new_region + + def union(self, region: Region) -> Region: + """Get a new region that contains both regions. + + Args: + region (Region): [description] + + Returns: + Region: [description] + """ + x1, y1, x2, y2 = self.corners + ox1, oy1, ox2, oy2 = region.corners + + union_region = Region.from_corners( + min(x1, ox1), min(y1, oy1), max(x2, ox2), max(y2, oy2) + ) + return union_region diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index 33ab2e26e..8f07c2a9c 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -9,11 +9,6 @@ from .geometry import Region, Dimensions from .widget import Widget -class Order(NamedTuple): - layer: int - z: int - - class RenderRegion(NamedTuple): region: Region order: tuple[int, ...] @@ -22,9 +17,13 @@ class RenderRegion(NamedTuple): class LayoutMap: def __init__(self, size: Dimensions) -> None: - self.size = size + self.region = size.region self.widgets: dict[Widget, RenderRegion] = {} + @property + def size(self) -> Dimensions: + return self.region.size + def __getitem__(self, widget: Widget) -> RenderRegion: return self.widgets[widget] @@ -52,6 +51,7 @@ class LayoutMap: region += widget.layout_offset self.widgets[widget] = RenderRegion(region, order, clip) + self.region = self.region.union(region.intersection(clip)) if isinstance(widget, View): sub_map = widget.layout.generate_map(console, region.size, region) diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 330f47e43..3cc2e6dcd 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -8,9 +8,9 @@ from typing import Iterable, TYPE_CHECKING, Sequence from rich.console import Console from .._layout_resolve import layout_resolve -from ..geometry import Region, Point, Dimensions +from ..geometry import Region, Dimensions from ..layout import Layout -from ..layout_map import LayoutMap, Order +from ..layout_map import LayoutMap if sys.version_info >= (3, 8): from typing import Literal diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 2847b2216..4fcf761bc 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -1,58 +1,60 @@ from __future__ import annotations +from typing import Iterable + from rich.console import Console -from ..geometry import Point, Region, Dimensions +from ..geometry import Region, Dimensions from ..layout import Layout +from ..layout_map import LayoutMap from ..widget import Widget -from ..view import View class VerticalLayout(Layout): - def __init__(self, gutter: tuple[int, int] = (0, 1)): - self.gutter = gutter or (0, 0) + def __init__(self, *, z: int = 0, gutter: tuple[int, int] | None = None): + self.z = z + self.gutter = gutter or (0, 1) self._widgets: list[Widget] = [] super().__init__() def add(self, widget: Widget) -> None: self._widgets.append(widget) + def clear(self) -> None: + del self._widgets[:] + + def get_widgets(self) -> Iterable[Widget]: + return self._widgets + def generate_map( self, console: Console, size: Dimensions, viewport: Region - ) -> WidgetMap: + ) -> LayoutMap: offset = viewport.origin + index = 0 width, height = size gutter_width, gutter_height = self.gutter render_width = width - gutter_width * 2 x = gutter_width y = gutter_height - map: dict[Widget, RenderRegion] = {} + map: LayoutMap = LayoutMap(size) - def add_widget(widget: Widget, region: Region): - order = (0, 0) - region = region + widget.layout_offset - map[widget] = RenderRegion(region, order, offset) - if isinstance(widget, View): - sub_map = widget.layout.generate_map( - console, - Dimensions(region.width, region.height), - region.origin + offset, - ) - map.update(sub_map) + def add_widget(widget: Widget, region: Region, clip: Region) -> None: + map.add_widget(console, widget, region, (self.z, index), clip) for widget in self._widgets: - region_lines = self.renders.get(widget) - if region_lines is None: + + try: + region, clip, lines = self.renders[widget] + except KeyError: renderable = widget.render() lines = console.render_lines( renderable, console.options.update_width(render_width) ) region = Region(x, y, render_width, len(lines)) - add_widget(widget, region) + add_widget(widget, region, viewport) else: - region, lines = region_lines - add_widget(widget, Region(x, y, region.width, region.height)) + add_widget(widget, Region(x, y, region.width, region.height), clip) y += region.height + gutter_height - widget_map = WidgetMap(Dimensions(width, y), map) - return widget_map + + return map diff --git a/src/textual/page.py b/src/textual/page.py index 2e759654e..630ec9f8e 100644 --- a/src/textual/page.py +++ b/src/textual/page.py @@ -101,24 +101,20 @@ class Page(Widget): self._page = PageRender(self, renderable, style=style) super().__init__(name=name) - x: Reactive[int] = Reactive(0) - y: Reactive[int] = Reactive(0) + scroll_x: Reactive[int] = Reactive(0) + scroll_y: Reactive[int] = Reactive(0) - @property - def contents_size(self) -> Dimensions: - return self._page.size - - def validate_x(self, value: int) -> int: + def validate_scroll_x(self, value: int) -> int: return max(0, value) - def validate_y(self, value: int) -> int: + def validate_scroll_y(self, value: int) -> int: return max(0, value) - async def watch_x(self, new: int) -> None: + async def watch_scroll_x(self, new: int) -> None: x, y = self._page.offset self._page.offset = Point(new, y) - async def watch_y(self, new: int) -> None: + async def watch_scroll_y(self, new: int) -> None: x, y = self._page.offset self._page.offset = Point(x, new) diff --git a/src/textual/view.py b/src/textual/view.py index e01245485..9d5fad63b 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -45,40 +45,46 @@ class View(Widget): super().__init_subclass__(**kwargs) background: Reactive[str] = Reactive("") - offset_x: Reactive[int] = Reactive(0) - offset_y: Reactive[int] = Reactive(0) - virtual_width: Reactive[int | None] = Reactive(None) - virtual_height: Reactive[int | None] = Reactive(None) async def watch_background(self, value: str) -> None: self.layout.background = value + scroll_x: Reactive[int] = Reactive(0) + scroll_y: Reactive[int] = Reactive(0) + @property def virtual_size(self) -> Dimensions: - virtual_width = self.virtual_width - virtual_height = self.virtual_height - return Dimensions( - (virtual_width if virtual_width is not None else self.size.width), - (virtual_height if virtual_height is not None else self.size.height), - ) + return self.layout.map.size - @virtual_size.setter - def virtual_size(self, size: tuple[int, int]) -> None: - width, height = size - self.virtual_width = width - self.virtual_height = height + # virtual_width: Reactive[int | None] = Reactive(None) + # virtual_height: Reactive[int | None] = Reactive(None) - @property - def offset(self) -> Point: - return Point(self.offset_x, self.offset_y) + # @property + # def virtual_size(self) -> Dimensions: + # virtual_width = self.virtual_width + # virtual_height = self.virtual_height + # return Dimensions( + # (virtual_width if virtual_width is not None else self.size.width), + # (virtual_height if virtual_height is not None else self.size.height), + # ) - @property - def viewport(self) -> Region: - virtual_width = self.virtual_width - virtual_height = self.virtual_height - width = virtual_width if virtual_width is not None else self.size.width - height = virtual_height if virtual_height is not None else self.size.height - return Region(self.offset_x, self.offset_y, width, height) + # @virtual_size.setter + # def virtual_size(self, size: tuple[int, int]) -> None: + # width, height = size + # self.virtual_width = width + # self.virtual_height = height + + # @property + # def offset(self) -> Point: + # return Point(self.offset_x, self.offset_y) + + # @property + # def viewport(self) -> Region: + # virtual_width = self.virtual_width + # virtual_height = self.virtual_height + # width = virtual_width if virtual_width is not None else self.size.width + # height = virtual_height if virtual_height is not None else self.size.height + # return Region(self.offset_x, self.offset_y, width, height) def __rich_console__( self, console: Console, options: ConsoleOptions @@ -143,10 +149,10 @@ class View(Widget): return width, height = self.console.size - virtual_width, virtual_height = self.virtual_size - viewport = Region(self.offset_x, self.offset_y, width, height) + # virtual_width, virtual_height = self.virtual_size + viewport = Region(self.scroll_x, self.scroll_y, width, height) hidden, shown, resized = self.layout.reflow( - self.console, virtual_width, virtual_height, viewport + self.console, width, height, viewport ) self.app.refresh() diff --git a/src/textual/views/__init__.py b/src/textual/views/__init__.py index dd1c4dfd4..d0f5515b6 100644 --- a/src/textual/views/__init__.py +++ b/src/textual/views/__init__.py @@ -1,2 +1,3 @@ from ._dock_view import DockView, Dock, DockEdge from ._grid_view import GridView +from ._window_view import WindowView diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py new file mode 100644 index 000000000..92aca9fef --- /dev/null +++ b/src/textual/views/_window_view.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from ..layouts.vertical import VerticalLayout +from ..view import View +from ..widget import Widget + + +class WindowView(View, layout=VerticalLayout): + def __init__( + self, *, gutter: tuple[int, int] = (1, 1), name: str | None = None + ) -> None: + self.gutter = gutter + super().__init__(name=name) + + async def update(self, widget: Widget) -> None: + self.layout = VerticalLayout(gutter=self.gutter) + self.layout.add(widget) diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 0e2b4f185..ce679b0b5 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -46,23 +46,23 @@ class ScrollView(View): target_y: Reactive[float] = Reactive(0, repaint=False) def validate_x(self, value: float) -> float: - return clamp(value, 0, self.page.contents_size.width - self.size.width) + return clamp(value, 0, self.page.virtual_size.width - self.size.width) def validate_target_x(self, value: float) -> float: - return clamp(value, 0, self.page.contents_size.width - self.size.width) + return clamp(value, 0, self.page.virtual_size.width - self.size.width) def validate_y(self, value: float) -> float: - return clamp(value, 0, self.page.contents_size.height - self.size.height) + return clamp(value, 0, self.page.virtual_size.height - self.size.height) def validate_target_y(self, value: float) -> float: - return clamp(value, 0, self.page.contents_size.height - self.size.height) + return clamp(value, 0, self.page.virtual_size.height - self.size.height) async def watch_x(self, new_value: float) -> None: - self.page.x = round(new_value) + self.page.scroll_x = round(new_value) self.hscroll.position = round(new_value) async def watch_y(self, new_value: float) -> None: - self.page.y = round(new_value) + self.page.scroll_y = round(new_value) self.vscroll.position = round(new_value) async def update(self, renderabe: RenderableType) -> None: @@ -132,7 +132,7 @@ class ScrollView(View): async def key_end(self) -> None: self.target_x = 0 - self.target_y = self.page.contents_size.height - self.size.height + self.target_y = self.page.virtual_size.height - self.size.height self.animate("x", self.target_x, duration=1, easing="out_cubic") self.animate("y", self.target_y, duration=1, easing="out_cubic") diff --git a/tests/test_geometry.py b/tests/test_geometry.py index cef706f1f..9df876d8c 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -176,3 +176,7 @@ def test_region_intersection(): ) assert not Region(10, 10, 20, 30).intersection(Region(50, 50, 100, 200)) + + +def test_region_union(): + assert Region(5, 5, 10, 10).union(Region(20, 30, 10, 5)) == Region(5, 5, 25, 30) From e7908edc2fc443c3ebbeb951c17d5c5d2935fa54 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Jul 2021 20:13:17 +0100 Subject: [PATCH 14/36] Layout --- src/textual/layout.py | 17 ++++++++++++----- src/textual/layouts/vertical.py | 10 ++++++---- src/textual/view.py | 9 +++++---- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/textual/layout.py b/src/textual/layout.py index bb01da481..7ba4ce3be 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -69,7 +69,7 @@ class LayoutUpdate: class Layout(ABC): - """Responsible for arranging Widgets in a view.""" + """Responsible for arranging Widgets in a view and rendering them.""" def __init__(self) -> None: self._layout_map: LayoutMap | None = None @@ -95,9 +95,7 @@ class Layout(ABC): self.renders.clear() self._layout_map = None - def reflow( - self, console: Console, width: int, height: int, viewport: Region - ) -> ReflowResult: + def reflow(self, console: Console, width: int, height: int) -> ReflowResult: self.reset() map = self.generate_map( @@ -161,7 +159,16 @@ class Layout(ABC): def generate_map( self, console: Console, size: Dimensions, viewport: Region ) -> LayoutMap: - ... + """Generate a layout map that defines where on the screen the widgets will be drawn. + + Args: + console (Console): Console instance. + size (Dimensions): Size of container. + viewport (Region): Screen relative viewport. + + Returns: + LayoutMap: [description] + """ async def mount_all(self, view: "View") -> None: await view.mount(*self.get_widgets()) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 4fcf761bc..709af4179 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -5,7 +5,7 @@ from typing import Iterable from rich.console import Console -from ..geometry import Region, Dimensions +from ..geometry import Point, Region, Dimensions from ..layout import Layout from ..layout_map import LayoutMap from ..widget import Widget @@ -30,7 +30,6 @@ class VerticalLayout(Layout): def generate_map( self, console: Console, size: Dimensions, viewport: Region ) -> LayoutMap: - offset = viewport.origin index = 0 width, height = size gutter_width, gutter_height = self.gutter @@ -43,7 +42,6 @@ class VerticalLayout(Layout): map.add_widget(console, widget, region, (self.z, index), clip) for widget in self._widgets: - try: region, clip, lines = self.renders[widget] except KeyError: @@ -54,7 +52,11 @@ class VerticalLayout(Layout): region = Region(x, y, render_width, len(lines)) add_widget(widget, region, viewport) else: - add_widget(widget, Region(x, y, region.width, region.height), clip) + add_widget( + widget, + Region(x, y, region.width, region.height) - scroll_offset, + clip, + ) y += region.height + gutter_height return map diff --git a/src/textual/view.py b/src/textual/view.py index 9d5fad63b..66bb9fe44 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -52,6 +52,10 @@ class View(Widget): scroll_x: Reactive[int] = Reactive(0) scroll_y: Reactive[int] = Reactive(0) + @property + def scroll_offset(self) -> Point: + return Point(self.scroll_x, self.scroll_y) + @property def virtual_size(self) -> Dimensions: return self.layout.map.size @@ -150,10 +154,7 @@ class View(Widget): width, height = self.console.size # virtual_width, virtual_height = self.virtual_size - viewport = Region(self.scroll_x, self.scroll_y, width, height) - hidden, shown, resized = self.layout.reflow( - self.console, width, height, viewport - ) + hidden, shown, resized = self.layout.reflow(self.console, width, height) self.app.refresh() for widget in hidden: From de81cf6860830e366420bca38dcf2319bc6a4ca5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Jul 2021 20:15:37 +0100 Subject: [PATCH 15/36] point -> offset --- src/textual/app.py | 6 ++--- src/textual/events.py | 6 ++--- src/textual/geometry.py | 18 +++++++------- src/textual/layout.py | 4 ++-- src/textual/layouts/grid.py | 4 ++-- src/textual/layouts/vertical.py | 2 +- src/textual/page.py | 10 ++++---- src/textual/screen_update.py | 4 ++-- src/textual/scrollbar.py | 4 ++-- src/textual/view.py | 8 +++---- tests/test_geometry.py | 42 ++++++++++++++++----------------- 11 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index ef9dbb84a..e93b2c51f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -17,7 +17,7 @@ from . import events from . import actions from ._animator import Animator from .binding import Bindings, NoBinding -from .geometry import Point, Region +from .geometry import Offset, Region from . import log from ._context import active_app from ._event_broker import extract_handler_actions, NoHandler @@ -103,7 +103,7 @@ class App(MessagePump): self._action_targets = {"app", "view"} self._animator = Animator(self) self.animate = self._animator.bind(self) - self.mouse_position = Point(0, 0) + self.mouse_position = Offset(0, 0) self.bindings = Bindings() self._title = title @@ -370,7 +370,7 @@ class App(MessagePump): if isinstance(event, events.InputEvent): if isinstance(event, events.MouseEvent): - self.mouse_position = Point(event.x, event.y) + self.mouse_position = Offset(event.x, event.y) if isinstance(event, events.Key) and self.focused is not None: await self.focused.forward_event(event) await self.view.forward_event(event) diff --git a/src/textual/events.py b/src/textual/events.py index 3c8a25ca8..f9edeab39 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -5,7 +5,7 @@ from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar import rich.repr from rich.style import Style -from .geometry import Point, Dimensions +from .geometry import Offset, Dimensions from .message import Message from ._types import MessageTarget from .keys import Keys @@ -143,7 +143,7 @@ class MouseCapture(Event): """ - def __init__(self, sender: MessageTarget, mouse_position: Point) -> None: + def __init__(self, sender: MessageTarget, mouse_position: Offset) -> None: """ Args: @@ -161,7 +161,7 @@ class MouseCapture(Event): class MouseRelease(Event): """Mouse has been released.""" - def __init__(self, sender: MessageTarget, mouse_position: Point) -> None: + def __init__(self, sender: MessageTarget, mouse_position: Offset) -> None: """ Args: sender (MessageTarget): The sender of the event, (in this case the app). diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 1d55b7e54..ead7fa969 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -25,7 +25,7 @@ def clamp(value: T, minimum: T, maximum: T) -> T: return value -class Point(NamedTuple): +class Offset(NamedTuple): """A point defined by x and y coordinates.""" x: int @@ -36,21 +36,21 @@ class Point(NamedTuple): """Check if the point is at the origin (0, 0)""" return self == (0, 0) - def __add__(self, other: object) -> Point: + def __add__(self, other: object) -> Offset: if isinstance(other, tuple): _x, _y = self x, y = other - return Point(_x + x, _y + y) + return Offset(_x + x, _y + y) return NotImplemented - def __sub__(self, other: object) -> Point: + def __sub__(self, other: object) -> Offset: if isinstance(other, tuple): _x, _y = self x, y = other - return Point(_x - x, _y - y) + return Offset(_x - x, _y - y) return NotImplemented - def blend(self, destination: Point, factor: float) -> Point: + def blend(self, destination: Offset, factor: float) -> Offset: """Blend (interpolate) to a new point. Args: @@ -62,7 +62,7 @@ class Point(NamedTuple): """ x1, y1 = self x2, y2 = destination - return Point(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor))) + return Offset(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor))) class Dimensions(NamedTuple): @@ -173,9 +173,9 @@ class Region(NamedTuple): return self.width * self.height @property - def origin(self) -> Point: + def origin(self) -> Offset: """Get the start point of the region.""" - return Point(self.x, self.y) + return Offset(self.x, self.y) @property def size(self) -> Dimensions: diff --git a/src/textual/layout.py b/src/textual/layout.py index 7ba4ce3be..76e59eef9 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -20,7 +20,7 @@ from ._loop import loop_last from .layout_map import LayoutMap from ._types import Lines -from .geometry import clamp, Region, Point, Dimensions +from .geometry import clamp, Region, Offset, Dimensions PY38 = sys.version_info >= (3, 8) @@ -191,7 +191,7 @@ class Layout(ABC): for widget, (region, _order, clip) in layers: yield widget, region.intersection(clip) - def get_offset(self, widget: Widget) -> Point: + def get_offset(self, widget: Widget) -> Offset: try: return self.map[widget].region.origin except KeyError: diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 48363a095..b3ce81b44 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -11,7 +11,7 @@ from typing import Iterable, NamedTuple from rich.console import Console from .._layout_resolve import layout_resolve -from ..geometry import Dimensions, Point, Region +from ..geometry import Dimensions, Offset, Region from ..layout import Layout from ..layout_map import LayoutMap from ..widget import Widget @@ -369,7 +369,7 @@ class GridLayout(Layout): } order = 1 from_corners = Region.from_corners - gutter = Point(self.column_gutter, self.row_gutter) + gutter = Offset(self.column_gutter, self.row_gutter) for widget, area in widget_areas: column_start, column_end, row_start, row_end = self.areas[area] try: diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 709af4179..3677fa80b 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -5,7 +5,7 @@ from typing import Iterable from rich.console import Console -from ..geometry import Point, Region, Dimensions +from ..geometry import Offset, Region, Dimensions from ..layout import Layout from ..layout_map import LayoutMap from ..widget import Widget diff --git a/src/textual/page.py b/src/textual/page.py index 630ec9f8e..6816055c9 100644 --- a/src/textual/page.py +++ b/src/textual/page.py @@ -7,7 +7,7 @@ from rich.padding import Padding, PaddingDimensions from rich.segment import Segment from rich.style import StyleType -from .geometry import Dimensions, Point +from .geometry import Dimensions, Offset from .message import Message from .widget import Widget, Reactive @@ -35,14 +35,14 @@ class PageRender: self.height = height self.style = style self.padding = padding - self.offset = Point(0, 0) + self.offset = Offset(0, 0) self._render_width: int | None = None self._render_height: int | None = None self.size = Dimensions(0, 0) self._lines: list[list[Segment]] = [] def move_to(self, x: int = 0, y: int = 0) -> None: - self.offset = Point(x, y) + self.offset = Offset(x, y) def clear(self) -> None: self._render_width = None @@ -112,11 +112,11 @@ class Page(Widget): async def watch_scroll_x(self, new: int) -> None: x, y = self._page.offset - self._page.offset = Point(new, y) + self._page.offset = Offset(new, y) async def watch_scroll_y(self, new: int) -> None: x, y = self._page.offset - self._page.offset = Point(x, new) + self._page.offset = Offset(x, new) def update(self, renderable: RenderableType | None = None) -> None: if renderable: diff --git a/src/textual/screen_update.py b/src/textual/screen_update.py index 4814187c8..487d7094a 100644 --- a/src/textual/screen_update.py +++ b/src/textual/screen_update.py @@ -6,7 +6,7 @@ from rich.console import Console, RenderableType from rich.control import Control from rich.segment import Segment, Segments -from .geometry import Point +from .geometry import Offset from ._loop import loop_last @@ -18,7 +18,7 @@ class ScreenUpdate: self.lines = console.render_lines( renderable, console.options.update_dimensions(width, height) ) - self.offset = Point(0, 0) + self.offset = Offset(0, 0) def render(self, x: int, y: int) -> Iterable[Segment]: move_to = Control.move_to diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index ca0671cc5..cfa69dabc 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -7,7 +7,7 @@ from rich.segment import Segment, Segments from rich.style import Style, StyleType from . import events -from .geometry import Point +from .geometry import Offset from ._types import MessageTarget from .message import Message from .widget import Reactive, Widget @@ -184,7 +184,7 @@ class ScrollBar(Widget): window_size: Reactive[int] = Reactive(0) position: Reactive[int] = Reactive(0) mouse_over: Reactive[bool] = Reactive(False) - grabbed: Reactive[Point | None] = Reactive(None) + grabbed: Reactive[Offset | None] = Reactive(None) def __rich_repr__(self) -> rich.repr.RichReprResult: yield "virtual_size", self.virtual_size diff --git a/src/textual/view.py b/src/textual/view.py index 66bb9fe44..b57466186 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -10,7 +10,7 @@ from rich.style import Style from . import events from . import log from .layout import Layout, NoWidget -from .geometry import Dimensions, Point, Region +from .geometry import Dimensions, Offset, Region from .messages import UpdateMessage, LayoutMessage from .reactive import Reactive, watch @@ -53,8 +53,8 @@ class View(Widget): scroll_y: Reactive[int] = Reactive(0) @property - def scroll_offset(self) -> Point: - return Point(self.scroll_x, self.scroll_y) + def scroll_offset(self) -> Offset: + return Offset(self.scroll_x, self.scroll_y) @property def virtual_size(self) -> Dimensions: @@ -116,7 +116,7 @@ class View(Widget): def render(self) -> RenderableType: return self.layout - def get_offset(self, widget: Widget) -> Point: + def get_offset(self, widget: Widget) -> Offset: return self.layout.get_offset(widget) def check_layout(self) -> bool: diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 9df876d8c..cfe94fc74 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,6 +1,6 @@ import pytest -from textual.geometry import clamp, Point, Dimensions, Region +from textual.geometry import clamp, Offset, Dimensions, Region def test_dimensions_region(): @@ -18,13 +18,13 @@ def test_dimensions_contains(): def test_dimensions_contains_point(): - assert Dimensions(10, 10).contains_point(Point(5, 5)) - assert Dimensions(10, 10).contains_point(Point(9, 9)) - assert Dimensions(10, 10).contains_point(Point(0, 0)) - assert not Dimensions(10, 10).contains_point(Point(10, 9)) - assert not Dimensions(10, 10).contains_point(Point(9, 10)) - assert not Dimensions(10, 10).contains_point(Point(-1, 0)) - assert not Dimensions(10, 10).contains_point(Point(0, -1)) + assert Dimensions(10, 10).contains_point(Offset(5, 5)) + assert Dimensions(10, 10).contains_point(Offset(9, 9)) + assert Dimensions(10, 10).contains_point(Offset(0, 0)) + assert not Dimensions(10, 10).contains_point(Offset(10, 9)) + assert not Dimensions(10, 10).contains_point(Offset(9, 10)) + assert not Dimensions(10, 10).contains_point(Offset(-1, 0)) + assert not Dimensions(10, 10).contains_point(Offset(0, -1)) def test_dimensions_contains_special(): @@ -63,32 +63,32 @@ def test_clamp(): def test_point_is_origin(): - assert Point(0, 0).is_origin - assert not Point(1, 0).is_origin + assert Offset(0, 0).is_origin + assert not Offset(1, 0).is_origin def test_point_add(): - assert Point(1, 1) + Point(2, 2) == Point(3, 3) - assert Point(1, 2) + Point(3, 4) == Point(4, 6) + assert Offset(1, 1) + Offset(2, 2) == Offset(3, 3) + assert Offset(1, 2) + Offset(3, 4) == Offset(4, 6) with pytest.raises(TypeError): - Point(1, 1) + "foo" + Offset(1, 1) + "foo" def test_point_sub(): - assert Point(1, 1) - Point(2, 2) == Point(-1, -1) - assert Point(3, 4) - Point(2, 1) == Point(1, 3) + assert Offset(1, 1) - Offset(2, 2) == Offset(-1, -1) + assert Offset(3, 4) - Offset(2, 1) == Offset(1, 3) with pytest.raises(TypeError): - Point(1, 1) - "foo" + Offset(1, 1) - "foo" def test_point_blend(): - assert Point(1, 2).blend(Point(3, 4), 0) == Point(1, 2) - assert Point(1, 2).blend(Point(3, 4), 1) == Point(3, 4) - assert Point(1, 2).blend(Point(3, 4), 0.5) == Point(2, 3) + assert Offset(1, 2).blend(Offset(3, 4), 0) == Offset(1, 2) + assert Offset(1, 2).blend(Offset(3, 4), 1) == Offset(3, 4) + assert Offset(1, 2).blend(Offset(3, 4), 0.5) == Offset(2, 3) def test_region_from_origin(): - assert Region.from_origin(Point(3, 4), (5, 6)) == Region(3, 4, 5, 6) + assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) def test_region_area(): @@ -102,7 +102,7 @@ def test_region_size(): def test_region_origin(): - assert Region(1, 2, 3, 4).origin == Point(1, 2) + assert Region(1, 2, 3, 4).origin == Offset(1, 2) def test_region_add(): From ecc5d24b5419b952d0137f883e923e2ec1047af9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Jul 2021 21:04:25 +0100 Subject: [PATCH 16/36] fix cropping on scroll --- examples/grid.py | 20 ++++++++------------ src/textual/layout.py | 20 ++++++++++++++------ src/textual/layout_map.py | 4 +++- src/textual/layouts/dock.py | 12 ++++++------ src/textual/layouts/grid.py | 3 ++- src/textual/layouts/vertical.py | 4 ++-- src/textual/view.py | 6 ++++-- 7 files changed, 39 insertions(+), 30 deletions(-) diff --git a/examples/grid.py b/examples/grid.py index 4bb48929f..c31a26f42 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -10,7 +10,9 @@ class GridTest(App): async def on_mount(self, event: events.Mount) -> None: grid = await self.view.dock_grid(edge="left", size=70, name="left") - left = self.view["left"] + + self.view["left"].scroll_y = 5 + self.view["left"].scroll_x = 5 grid.add_column(fraction=1, name="left", min_size=20) grid.add_column(size=30, name="center") @@ -27,18 +29,12 @@ class GridTest(App): area4="right,top-start|middle-end", ) - def make_placeholder(name: str) -> Placeholder: - p = Placeholder(name=name) - p.layout_offset_x = 10 - p.layout_offset_y = 0 - return p - grid.place( - area1=make_placeholder(name="area1"), - area2=make_placeholder(name="area2"), - area3=make_placeholder(name="area3"), - area4=make_placeholder(name="area4"), + area1=Placeholder(name="area1"), + area2=Placeholder(name="area2"), + area3=Placeholder(name="area3"), + area4=Placeholder(name="area4"), ) -GridTest.run(title="Grid Test") +GridTest.run(title="Grid Test", log="textual.log") diff --git a/src/textual/layout.py b/src/textual/layout.py index 76e59eef9..81859124c 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -18,6 +18,7 @@ from rich.style import Style from . import log from ._loop import loop_last from .layout_map import LayoutMap +from ._lines import crop_lines from ._types import Lines from .geometry import clamp, Region, Offset, Dimensions @@ -95,11 +96,16 @@ class Layout(ABC): self.renders.clear() self._layout_map = None - def reflow(self, console: Console, width: int, height: int) -> ReflowResult: + def reflow( + self, console: Console, width: int, height: int, scroll: Offset + ) -> ReflowResult: self.reset() map = self.generate_map( - console, Dimensions(width, height), Region(0, 0, width, height) + console, + Dimensions(width, height), + Region(0, 0, width, height), + scroll, ) self._require_update = False @@ -157,7 +163,7 @@ class Layout(ABC): @abstractmethod def generate_map( - self, console: Console, size: Dimensions, viewport: Region + self, console: Console, size: Dimensions, viewport: Region, scroll: Offset ) -> LayoutMap: """Generate a layout map that defines where on the screen the widgets will be drawn. @@ -360,7 +366,6 @@ class Layout(ABC): for region, clip, lines in chain( renders, [(screen, screen, background_render)] ): - # region = region.intersection(clip) for y, line in enumerate(lines, region.y): if clip_y > y > clip_y2: continue @@ -396,6 +401,9 @@ class Layout(ABC): ) self.renders[widget] = (region, clip, new_lines) + update_lines = self.render(console, region.intersection(clip)).lines - update_lines = self.render(console, region).lines - return LayoutUpdate(update_lines, region.x, region.y) + clipped_region = region.intersection(clip) + update = LayoutUpdate(update_lines, clipped_region.x, clipped_region.y) + + return update diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index 8f07c2a9c..44a171e14 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -54,7 +54,9 @@ class LayoutMap: self.region = self.region.union(region.intersection(clip)) if isinstance(widget, View): - sub_map = widget.layout.generate_map(console, region.size, region) + sub_map = widget.layout.generate_map( + console, region.size, region, widget.scroll + ) for widget, (sub_region, sub_order, sub_clip) in sub_map.items(): sub_region += region.origin sub_clip = sub_clip.intersection(clip) diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 3cc2e6dcd..9bbba9cce 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -8,7 +8,7 @@ from typing import Iterable, TYPE_CHECKING, Sequence from rich.console import Console from .._layout_resolve import layout_resolve -from ..geometry import Region, Dimensions +from ..geometry import Offset, Region, Dimensions from ..layout import Layout from ..layout_map import LayoutMap @@ -49,7 +49,7 @@ class DockLayout(Layout): yield from dock.widgets def generate_map( - self, console: Console, size: Dimensions, viewport: Region + self, console: Console, size: Dimensions, viewport: Region, scroll: Offset ) -> LayoutMap: map: LayoutMap = LayoutMap(size) @@ -90,7 +90,7 @@ class DockLayout(Layout): add_widget(widget, Region(x, render_y, width, layout_size), order) render_y += layout_size remaining = max(0, remaining - layout_size) - region = Region(x, y + total, width, height - total) + region = Region(x, y + total, width, height - total) - scroll elif dock.edge == "bottom": sizes = layout_resolve(height, dock_options) @@ -111,7 +111,7 @@ class DockLayout(Layout): ) render_y -= layout_size remaining = max(0, remaining - layout_size) - region = Region(x, y, width, height - total) + region = Region(x, y, width, height - total) - scroll elif dock.edge == "left": sizes = layout_resolve(width, dock_options) @@ -128,7 +128,7 @@ class DockLayout(Layout): add_widget(widget, Region(render_x, y, layout_size, height), order) render_x += layout_size remaining = max(0, remaining - layout_size) - region = Region(x + total, y, width - total, height) + region = Region(x + total, y, width - total, height) - scroll elif dock.edge == "right": sizes = layout_resolve(width, dock_options) @@ -149,7 +149,7 @@ class DockLayout(Layout): ) render_x -= layout_size remaining = max(0, remaining - layout_size) - region = Region(x, y, width - total, height) + region = Region(x, y, width - total, height) - scroll layers[dock.z] = region diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index b3ce81b44..5a1d7c31e 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -264,7 +264,7 @@ class GridLayout(Layout): return self.widgets.keys() def generate_map( - self, console: Console, size: Dimensions, viewport: Region + self, console: Console, size: Dimensions, viewport: Region, scroll: Offset ) -> LayoutMap: """Generate a map that associates widgets with their location on screen. @@ -328,6 +328,7 @@ class GridLayout(Layout): return names, tracks, len(spans), max_size def add_widget(widget: Widget, region: Region, order: tuple[int, int]): + region -= scroll map.add_widget(console, widget, region, order, viewport) # region = region + widget.layout_offset # map[widget] = RenderRegion(region, order, offset) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 3677fa80b..b14db585f 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -28,7 +28,7 @@ class VerticalLayout(Layout): return self._widgets def generate_map( - self, console: Console, size: Dimensions, viewport: Region + self, console: Console, size: Dimensions, viewport: Region, scroll: Offset ) -> LayoutMap: index = 0 width, height = size @@ -54,7 +54,7 @@ class VerticalLayout(Layout): else: add_widget( widget, - Region(x, y, region.width, region.height) - scroll_offset, + Region(x, y, region.width, region.height) - scroll, clip, ) y += region.height + gutter_height diff --git a/src/textual/view.py b/src/textual/view.py index b57466186..ce5d494f4 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -53,7 +53,7 @@ class View(Widget): scroll_y: Reactive[int] = Reactive(0) @property - def scroll_offset(self) -> Offset: + def scroll(self) -> Offset: return Offset(self.scroll_x, self.scroll_y) @property @@ -154,7 +154,9 @@ class View(Widget): width, height = self.console.size # virtual_width, virtual_height = self.virtual_size - hidden, shown, resized = self.layout.reflow(self.console, width, height) + hidden, shown, resized = self.layout.reflow( + self.console, width, height, self.scroll + ) self.app.refresh() for widget in hidden: From 6cb25add4a027901cb6c9a15d0aa0fccfd88280d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Jul 2021 16:53:54 +0100 Subject: [PATCH 17/36] renderer refactor --- examples/README.md | 5 ++ examples/test_layout.py | 20 +++++++ examples/vertical.py | 19 ++++++ src/textual/__init__.py | 9 +++ src/textual/app.py | 19 +++--- src/textual/geometry.py | 24 ++++++++ src/textual/layout.py | 90 +++++++++++++++++------------ src/textual/layouts/vertical.py | 5 +- src/textual/message_pump.py | 2 +- src/textual/view.py | 2 +- src/textual/views/_window_view.py | 17 ++++-- src/textual/widgets/_placeholder.py | 8 +++ src/textual/widgets/_scroll_view.py | 33 ++++++----- 13 files changed, 187 insertions(+), 66 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/test_layout.py create mode 100644 examples/vertical.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..9583a061c --- /dev/null +++ b/examples/README.md @@ -0,0 +1,5 @@ +# Examples + +Run any of these examples to demonstrate a features. + +These examples may not be feature complete, but they should be somewhat useful and a good starting point for your own code. diff --git a/examples/test_layout.py b/examples/test_layout.py new file mode 100644 index 000000000..1fa855e5e --- /dev/null +++ b/examples/test_layout.py @@ -0,0 +1,20 @@ +from rich import print +from rich.console import Console + +from textual.geometry import Offset, Region +from textual.widgets import Placeholder + + +from textual.views import WindowView + +p = Placeholder(height=10) +view = WindowView(p) + +console = Console() +view.layout.reflow(console, 30, 25, Offset(0, 3)) + +print(view.layout._layout_map.widgets) + +console.print(view.layout.render(console)) + +# console.print(view.layout.render(console, Region(100, 2, 10, 10))) diff --git a/examples/vertical.py b/examples/vertical.py new file mode 100644 index 000000000..2f43a4f33 --- /dev/null +++ b/examples/vertical.py @@ -0,0 +1,19 @@ +from textual import events +from textual.app import App + +from textual.views import WindowView +from textual.widgets import Placeholder + + +class MyApp(App): + async def on_mount(self, event: events.Mount) -> None: + window1 = WindowView(Placeholder(height=20)) + # window2 = WindowView(Placeholder(height=20)) + + # window1.scroll_x = -10 + # window1.scroll_y = 5 + + await self.view.dock(window1, edge="left") + + +MyApp.run(log="textual.log") diff --git a/src/textual/__init__.py b/src/textual/__init__.py index c81db2d12..81000402d 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -1,8 +1,17 @@ from typing import Any +__all__ = ["log", "panic"] + def log(*args: Any) -> None: from ._context import active_app app = active_app.get() app.log(*args) + + +def panic(*args: Any) -> None: + from ._context import active_app + + app = active_app.get() + app.panic(*args) diff --git a/src/textual/app.py b/src/textual/app.py index e93b2c51f..14a71d571 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -97,7 +97,7 @@ class App(MessagePump): self.mouse_over: Widget | None = None self.mouse_captured: Widget | None = None self._driver: Driver | None = None - self._tracebacks: list[Traceback] = [] + self._exit_renderables: list[RenderableType] = [] self._docks: list[Dock] = [] self._action_targets = {"app", "view"} @@ -231,16 +231,19 @@ class App(MessagePump): if widget is not None: await widget.post_message(events.MouseCapture(self, self.mouse_position)) - def panic(self, traceback: Traceback | None = None) -> None: + def panic(self, *renderables: RenderableType) -> None: """Exits the app with a traceback. Args: traceback (Traceback, optional): Rich Traceback object or None to generate one for the most recent exception. Defaults to None. """ - if traceback is None: - traceback = Traceback(show_locals=True) - self._tracebacks.append(traceback) + + if not renderables: + renderables = ( + Traceback(show_locals=True, width=None, locals_max_length=5), + ) + self._exit_renderables.extend(renderables) self.close_messages_no_wait() async def process_messages(self) -> None: @@ -283,8 +286,8 @@ class App(MessagePump): self.panic() finally: driver.stop_application_mode() - if self._tracebacks: - for traceback in self._tracebacks: + if self._exit_renderables: + for traceback in self._exit_renderables: self.error_console.print(traceback) if self.log_file is not None: self.log_file.close() @@ -339,7 +342,7 @@ class App(MessagePump): console.file.write("\x1bP=2s\x1b\\") console.file.flush() except Exception: - self.panic(Traceback(show_locals=True)) + self.panic() def display(self, renderable: RenderableType) -> None: if not self._closed: diff --git a/src/textual/geometry.py b/src/textual/geometry.py index ead7fa969..e1749fbf1 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -167,6 +167,22 @@ class Region(NamedTuple): def __bool__(self) -> bool: return bool(self.width and self.height) + @property + def x_extents(self) -> tuple[int, int]: + return (self.x, self.x + self.width) + + @property + def y_extents(self) -> tuple[int, int]: + return (self.y, self.y + self.height) + + @property + def x_end(self) -> int: + return self.x + self.width + + @property + def y_end(self) -> int: + return self.y + self.height + @property def area(self) -> int: """Get the area within the region.""" @@ -192,6 +208,14 @@ class Region(NamedTuple): x, y, width, height = self return x, y, x + width, y + height + @property + def x_range(self) -> range: + return range(self.x, self.x_end) + + @property + def y_range(self) -> range: + return range(self.y, self.y_end) + def __add__(self, other: Any) -> Region: if isinstance(other, tuple): ox, oy = other diff --git a/src/textual/layout.py b/src/textual/layout.py index 81859124c..bf39801ce 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -15,7 +15,7 @@ from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.segment import Segment, SegmentLines from rich.style import Style -from . import log +from . import log, panic from ._loop import loop_last from .layout_map import LayoutMap from ._lines import crop_lines @@ -101,6 +101,9 @@ class Layout(ABC): ) -> ReflowResult: self.reset() + self.width = width + self.height = height + map = self.generate_map( console, Dimensions(width, height), @@ -131,8 +134,6 @@ class Layout(ABC): hidden_widgets = old_widgets - new_widgets self._layout_map = map - self.width = width - self.height = height # Copy renders if the size hasn't changed new_renders = { @@ -255,9 +256,10 @@ class Layout(ABC): if self.map is not None: for region, order, clip in self.map.values(): region = region.intersection(clip) - if region and (region in screen_region): # type: ignore - for y in range(region.y, region.y + region.height): - cuts_sets[y].update({region.x, region.x + region.width}) + if region and (region in screen_region): + region_cuts = region.x_extents + for y in region.y_range: + cuts_sets[y].update(region_cuts) # Sort the cuts for each line self._cuts = [sorted(cut_set) for cut_set in cuts_sets] @@ -265,9 +267,6 @@ class Layout(ABC): def _get_renders(self, console: Console) -> Iterable[tuple[Region, Region, Lines]]: _rich_traceback_guard = True - width = self.width - height = self.height - screen_region = Region(0, 0, width, height) layout_map = self.map if layout_map: @@ -292,12 +291,12 @@ class Layout(ABC): if not widget.is_visual: continue + region_lines = self.renders.get(widget) if region_lines is not None: - yield region_lines - continue - - lines = render(widget, region.width, region.height) + region, clip, lines = region_lines + else: + lines = render(widget, region.width, region.height) if region in clip: self.renders[widget] = (region, clip, lines) yield region, clip, lines @@ -308,11 +307,10 @@ class Layout(ABC): self.renders[widget] = (region, clip, lines) splits = [delta_x, delta_x + new_region.width] + lines = lines[delta_y : delta_y + new_region.height] + divide = Segment.divide - lines = [ - list(divide(line, splits))[1] - for line in lines[delta_y : delta_y + new_region.height] - ] + lines = [list(divide(line, splits))[1] for line in lines] yield region, clip, lines @classmethod @@ -331,7 +329,7 @@ class Layout(ABC): def render( self, console: Console, - clip: Region = None, + crop: Region = None, ) -> SegmentLines: """Render a layout. @@ -345,8 +343,10 @@ class Layout(ABC): width = self.width height = self.height screen = Region(0, 0, width, height) - clip = clip or screen - clip_x, clip_y, clip_x2, clip_y2 = clip.corners + + crop_region = crop or Region(0, 0, self.width, self.height) + + # clip_x, clip_y, clip_x2, clip_y2 = clip.corners divide = Segment.divide @@ -363,27 +363,45 @@ class Layout(ABC): ] # Go through all the renders in reverse order and fill buckets with no render renders = self._get_renders(console) + clip_y, clip_y2 = crop_region.y_extents for region, clip, lines in chain( renders, [(screen, screen, background_render)] ): - for y, line in enumerate(lines, region.y): + # clip = clip.intersection(crop_region) + render_region = region.intersection(clip) + for y, line in enumerate(lines, render_region.y): if clip_y > y > clip_y2: continue - first_cut = clamp(region.x, clip_x, clip_x2) - last_cut = clamp(region.x + region.width, clip_x, clip_x2) + # first_cut = clamp(render_region.x, clip_x, clip_x2) + # last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2) + first_cut = render_region.x + last_cut = render_region.x_end final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - if len(final_cuts) > 1: - if final_cuts == [region.x, region.x + region.width]: - cut_segments = [line] - else: - relative_cuts = [cut - region.x for cut in final_cuts] - _, *cut_segments = divide(line, relative_cuts) - for cut, segments in zip(final_cuts, cut_segments): - if chops[y][cut] is None: - chops[y][cut] = segments + # final_cuts = cuts[y] + + if final_cuts == render_region.y_extents: + cut_segments = [line] + else: + render_x = render_region.x + relative_cuts = [cut - render_x for cut in final_cuts] + _, *cut_segments = divide(line, relative_cuts) + for cut, segments in zip(final_cuts, cut_segments): + if chops[y][cut] is None: + chops[y][cut] = segments # Assemble the cut renders in to lists of segments - output_lines = list(self._assemble_chops(chops[clip_y:clip_y2])) + output_lines = list(self._assemble_chops(chops)) + + def width_view(line: list[Segment]) -> list[Segment]: + if line: + div_lines = list(Segment.divide(line, [crop_x, crop_x2])) + line = div_lines[1] if len(div_lines) > 1 else div_lines[0] + return line + + if crop is not None: + crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners + output_lines = [width_view(line) for line in output_lines[crop_y:crop_y2]] + return SegmentLines(output_lines, new_lines=True) def __rich_console__( @@ -401,9 +419,9 @@ class Layout(ABC): ) self.renders[widget] = (region, clip, new_lines) - update_lines = self.render(console, region.intersection(clip)).lines - clipped_region = region.intersection(clip) - update = LayoutUpdate(update_lines, clipped_region.x, clipped_region.y) + update_region = region.intersection(clip) + update_lines = self.render(console, update_region).lines + update = LayoutUpdate(update_lines, update_region.x, update_region.y) return update diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index b14db585f..6c45121ee 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -14,7 +14,7 @@ from ..widget import Widget class VerticalLayout(Layout): def __init__(self, *, z: int = 0, gutter: tuple[int, int] | None = None): self.z = z - self.gutter = gutter or (0, 1) + self.gutter = gutter or (1, 1) self._widgets: list[Widget] = [] super().__init__() @@ -49,8 +49,9 @@ class VerticalLayout(Layout): lines = console.render_lines( renderable, console.options.update_width(render_width) ) + region = Region(x, y, render_width, len(lines)) - add_widget(widget, region, viewport) + add_widget(widget, region - scroll, viewport) else: add_widget( widget, diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 3cd2142cd..aa08a7f7f 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -193,7 +193,7 @@ class MessagePump: except CancelledError: raise except Exception as error: - self.app.panic(Traceback(show_locals=True)) + self.app.panic() break finally: if isinstance(message, events.Event) and self._message_queue.empty(): diff --git a/src/textual/view.py b/src/textual/view.py index ce5d494f4..35c51156d 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -58,7 +58,7 @@ class View(Widget): @property def virtual_size(self) -> Dimensions: - return self.layout.map.size + return self.layout.map.size if self.layout.map else Dimensions(0, 0) # virtual_width: Reactive[int | None] = Reactive(None) # virtual_height: Reactive[int | None] = Reactive(None) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 92aca9fef..fde738e86 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -1,17 +1,26 @@ from __future__ import annotations +from rich.console import RenderableType + from ..layouts.vertical import VerticalLayout from ..view import View from ..widget import Widget +from ..widgets import Static class WindowView(View, layout=VerticalLayout): def __init__( - self, *, gutter: tuple[int, int] = (1, 1), name: str | None = None + self, + widget: RenderableType | Widget, + *, + gutter: tuple[int, int] = (1, 1), + name: str | None = None ) -> None: self.gutter = gutter - super().__init__(name=name) + layout = VerticalLayout() + layout.add(widget if isinstance(widget, Widget) else Static(widget)) + super().__init__(name=name, layout=layout) - async def update(self, widget: Widget) -> None: + async def update(self, widget: Widget | RenderableType) -> None: self.layout = VerticalLayout(gutter=self.gutter) - self.layout.add(widget) + self.layout.add(widget if isinstance(widget, Widget) else Static(widget)) diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 1b3e37e5e..3c7de51c7 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from rich import box from rich.align import Align from rich.console import RenderableType @@ -19,6 +21,11 @@ class Placeholder(Widget, can_focus=True): has_focus: Reactive[bool] = Reactive(False) mouse_over: Reactive[bool] = Reactive(False) style: Reactive[str] = Reactive("") + height: Reactive[int | None] = Reactive(None) + + def __init__(self, *, name: str | None = None, height: int | None = None) -> None: + super().__init__(name=name) + self.height = height def __rich_repr__(self) -> rich.repr.RichReprResult: yield "name", self.name @@ -34,6 +41,7 @@ class Placeholder(Widget, can_focus=True): border_style="green" if self.mouse_over else "blue", box=box.HEAVY if self.has_focus else box.ROUNDED, style=self.style, + height=self.height, ) async def on_focus(self, event: events.Focus) -> None: diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index ce679b0b5..0923e6fa9 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -11,6 +11,8 @@ from ..scrollbar import ScrollTo, ScrollBar from ..geometry import clamp from ..page import Page from ..view import View + + from ..reactive import Reactive @@ -23,10 +25,12 @@ class ScrollView(View): style: StyleType = "", fluid: bool = True, ) -> None: + from ..views import WindowView + self.fluid = fluid self.vscroll = ScrollBar(vertical=True) self.hscroll = ScrollBar(vertical=False) - self.page = Page(renderable or "", style=style) + self.window = WindowView(renderable or "") layout = GridLayout() layout.add_column("main") layout.add_column("vscroll", size=1) @@ -46,33 +50,33 @@ class ScrollView(View): target_y: Reactive[float] = Reactive(0, repaint=False) def validate_x(self, value: float) -> float: - return clamp(value, 0, self.page.virtual_size.width - self.size.width) + return clamp(value, 0, self.window.virtual_size.width - self.size.width) def validate_target_x(self, value: float) -> float: - return clamp(value, 0, self.page.virtual_size.width - self.size.width) + return clamp(value, 0, self.window.virtual_size.width - self.size.width) def validate_y(self, value: float) -> float: - return clamp(value, 0, self.page.virtual_size.height - self.size.height) + return clamp(value, 0, self.window.virtual_size.height - self.size.height) def validate_target_y(self, value: float) -> float: - return clamp(value, 0, self.page.virtual_size.height - self.size.height) + return clamp(value, 0, self.window.virtual_size.height - self.size.height) async def watch_x(self, new_value: float) -> None: - self.page.scroll_x = round(new_value) + self.window.scroll_x = round(new_value) self.hscroll.position = round(new_value) async def watch_y(self, new_value: float) -> None: - self.page.scroll_y = round(new_value) + self.window.scroll_y = round(new_value) self.vscroll.position = round(new_value) async def update(self, renderabe: RenderableType) -> None: - self.page.update(renderabe) + await self.window.update(renderabe) self.require_repaint() async def on_mount(self, event: events.Mount) -> None: assert isinstance(self.layout, GridLayout) self.layout.place( - content=self.page, + content=self.window, vscroll=self.vscroll, hscroll=self.hscroll, ) @@ -132,7 +136,7 @@ class ScrollView(View): async def key_end(self) -> None: self.target_x = 0 - self.target_y = self.page.virtual_size.height - self.size.height + self.target_y = self.window.virtual_size.height - self.size.height self.animate("x", self.target_x, duration=1, easing="out_cubic") self.animate("y", self.target_y, duration=1, easing="out_cubic") @@ -143,8 +147,9 @@ class ScrollView(View): self.animate("y", self.target_y, duration=1, easing="out_cubic") async def on_resize(self, event: events.Resize) -> None: + return if self.fluid: - self.page.update() + self.window.update() async def message_scroll_up(self, message: Message) -> None: self.page_up() @@ -169,16 +174,16 @@ class ScrollView(View): async def message_page_update(self, message: Message) -> None: self.x = self.validate_x(self.x) self.y = self.validate_y(self.y) - self.vscroll.virtual_size = self.page.virtual_size.height + self.vscroll.virtual_size = self.window.virtual_size.height self.vscroll.window_size = self.size.height assert isinstance(self.layout, GridLayout) if self.layout.show_column( - "vscroll", self.page.virtual_size.height > self.size.height + "vscroll", self.window.virtual_size.height > self.size.height ): self.require_layout() if self.layout.show_row( - "hscroll", self.page.virtual_size.width > self.size.width + "hscroll", self.window.virtual_size.width > self.size.width ): self.require_layout() From 9b115180c0b967851ec5b73b371a399224656547 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Jul 2021 16:54:25 +0100 Subject: [PATCH 18/36] nl --- src/textual/_lines.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/textual/_lines.py diff --git a/src/textual/_lines.py b/src/textual/_lines.py new file mode 100644 index 000000000..9dfca984f --- /dev/null +++ b/src/textual/_lines.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from rich.segment import Segment + +from .geometry import Region +from ._types import Lines + + +def crop_lines(lines: Lines, clip: Region) -> Lines: + lines = lines[clip.y : clip.y + clip.height] + + def width_view(line: list[Segment]) -> list[Segment]: + _, line = Segment.divide(line, [clip.x, clip.x + clip.width]) + return line + + cropped_lines = [width_view(line) for line in lines] + return cropped_lines From 4ad342d9f27c248d2de0cd0e76df4d05b5ecbf7d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Jul 2021 20:16:12 +0100 Subject: [PATCH 19/36] code viewer back --- src/textual/layout_map.py | 4 ++-- src/textual/layouts/vertical.py | 3 +-- src/textual/views/_window_view.py | 7 +++++-- src/textual/widgets/_scroll_view.py | 5 +++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index 44a171e14..4855efa6b 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -51,11 +51,11 @@ class LayoutMap: region += widget.layout_offset self.widgets[widget] = RenderRegion(region, order, clip) - self.region = self.region.union(region.intersection(clip)) + self.region = self.region.union(region) if isinstance(widget, View): sub_map = widget.layout.generate_map( - console, region.size, region, widget.scroll + console, region.size, clip, widget.scroll ) for widget, (sub_region, sub_order, sub_clip) in sub_map.items(): sub_region += region.origin diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 6c45121ee..d0d46d217 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -14,7 +14,7 @@ from ..widget import Widget class VerticalLayout(Layout): def __init__(self, *, z: int = 0, gutter: tuple[int, int] | None = None): self.z = z - self.gutter = gutter or (1, 1) + self.gutter = gutter or (0, 0) self._widgets: list[Widget] = [] super().__init__() @@ -49,7 +49,6 @@ class VerticalLayout(Layout): lines = console.render_lines( renderable, console.options.update_width(render_width) ) - region = Region(x, y, render_width, len(lines)) add_widget(widget, region - scroll, viewport) else: diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index fde738e86..57941a74d 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -22,5 +22,8 @@ class WindowView(View, layout=VerticalLayout): super().__init__(name=name, layout=layout) async def update(self, widget: Widget | RenderableType) -> None: - self.layout = VerticalLayout(gutter=self.gutter) - self.layout.add(widget if isinstance(widget, Widget) else Static(widget)) + layout = self.layout + assert isinstance(layout, VerticalLayout) + layout.clear() + layout.add(widget if isinstance(widget, Widget) else Static(widget)) + self.require_layout() diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 0923e6fa9..9e978423f 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -1,4 +1,5 @@ from __future__ import annotations +from logging import PlaceHolder from rich.console import RenderableType from rich.style import StyleType @@ -11,6 +12,7 @@ from ..scrollbar import ScrollTo, ScrollBar from ..geometry import clamp from ..page import Page from ..view import View +from ..widgets import Placeholder from ..reactive import Reactive @@ -30,7 +32,7 @@ class ScrollView(View): self.fluid = fluid self.vscroll = ScrollBar(vertical=True) self.hscroll = ScrollBar(vertical=False) - self.window = WindowView(renderable or "") + self.window = WindowView("" if renderable is None else renderable) layout = GridLayout() layout.add_column("main") layout.add_column("vscroll", size=1) @@ -71,7 +73,6 @@ class ScrollView(View): async def update(self, renderabe: RenderableType) -> None: await self.window.update(renderabe) - self.require_repaint() async def on_mount(self, event: events.Mount) -> None: assert isinstance(self.layout, GridLayout) From 72b3b58ef5b480ee9ebf1e6c240826b21ea9555d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Jul 2021 21:20:15 +0100 Subject: [PATCH 20/36] virtual size --- examples/code_viewer.py | 4 +++- src/textual/events.py | 2 +- src/textual/layout_map.py | 9 +++++---- src/textual/view.py | 9 ++++++--- src/textual/views/_window_view.py | 10 ++++++++++ src/textual/widgets/_scroll_view.py | 30 ++++++++++++++--------------- 6 files changed, 40 insertions(+), 24 deletions(-) diff --git a/examples/code_viewer.py b/examples/code_viewer.py index 6fbdbafbf..9a0175e8e 100644 --- a/examples/code_viewer.py +++ b/examples/code_viewer.py @@ -29,7 +29,9 @@ class MyApp(App): await self.view.dock(Header(), edge="top") await self.view.dock(Footer(), edge="bottom") - await self.view.dock(self.directory, edge="left", size=32, name="sidebar") + await self.view.dock( + ScrollView(self.directory), edge="left", size=32, name="sidebar" + ) await self.view.dock(self.body, edge="right") async def message_file_click(self, message: FileClick) -> None: diff --git a/src/textual/events.py b/src/textual/events.py index f9edeab39..cdb3b9b83 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -127,7 +127,7 @@ class Show(Event): class Hide(Event): - """Send when a widget has been hidden. + """Sent when a widget has been hidden. A widget may be hidden by setting its `visible` flag to `False`, if it is no longer in a layout, or if it has been offset beyond the edges of the terminal. diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index 4855efa6b..be42e610d 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -17,12 +17,13 @@ class RenderRegion(NamedTuple): class LayoutMap: def __init__(self, size: Dimensions) -> None: - self.region = size.region + self.size = size + self.contents_region = Region(0, 0, 0, 0) self.widgets: dict[Widget, RenderRegion] = {} @property - def size(self) -> Dimensions: - return self.region.size + def virtual_size(self) -> Dimensions: + return self.contents_region.size def __getitem__(self, widget: Widget) -> RenderRegion: return self.widgets[widget] @@ -51,7 +52,7 @@ class LayoutMap: region += widget.layout_offset self.widgets[widget] = RenderRegion(region, order, clip) - self.region = self.region.union(region) + self.contents_region = self.contents_region.union(region) if isinstance(widget, View): sub_map = widget.layout.generate_map( diff --git a/src/textual/view.py b/src/textual/view.py index 35c51156d..358fc109e 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -56,9 +56,11 @@ class View(Widget): def scroll(self) -> Offset: return Offset(self.scroll_x, self.scroll_y) - @property - def virtual_size(self) -> Dimensions: - return self.layout.map.size if self.layout.map else Dimensions(0, 0) + virtual_size: Reactive[Dimensions] = Reactive(Dimensions(0, 0)) + + # @property + # def virtual_size(self) -> Dimensions: + # return self.layout.map.size if self.layout.map else Dimensions(0, 0) # virtual_width: Reactive[int | None] = Reactive(None) # virtual_height: Reactive[int | None] = Reactive(None) @@ -157,6 +159,7 @@ class View(Widget): hidden, shown, resized = self.layout.reflow( self.console, width, height, self.scroll ) + self.virtual_size = self.layout.map.virtual_size self.app.refresh() for widget in hidden: diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 57941a74d..36dde2da2 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -2,12 +2,18 @@ from __future__ import annotations from rich.console import RenderableType +from ..geometry import Offset, Dimensions from ..layouts.vertical import VerticalLayout from ..view import View +from ..message import Message from ..widget import Widget from ..widgets import Static +class VirtualSizeChange(Message): + pass + + class WindowView(View, layout=VerticalLayout): def __init__( self, @@ -26,4 +32,8 @@ class WindowView(View, layout=VerticalLayout): assert isinstance(layout, VerticalLayout) layout.clear() layout.add(widget if isinstance(widget, Widget) else Static(widget)) + await self.refresh_layout() self.require_layout() + + async def watch_virtual_size(self, size: Dimensions) -> None: + await self.emit(VirtualSizeChange(self)) diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 9e978423f..bf9066bab 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -9,11 +9,11 @@ from .. import events from ..layouts.grid import GridLayout from ..message import Message from ..scrollbar import ScrollTo, ScrollBar -from ..geometry import clamp +from ..geometry import clamp, Offset, Dimensions from ..page import Page +from ..reactive import watch from ..view import View -from ..widgets import Placeholder - +from ..widget import Widget from ..reactive import Reactive @@ -21,7 +21,7 @@ from ..reactive import Reactive class ScrollView(View): def __init__( self, - renderable: RenderableType | None = None, + contents: RenderableType | Widget | None = None, *, name: str | None = None, style: StyleType = "", @@ -32,7 +32,7 @@ class ScrollView(View): self.fluid = fluid self.vscroll = ScrollBar(vertical=True) self.hscroll = ScrollBar(vertical=False) - self.window = WindowView("" if renderable is None else renderable) + self.window = WindowView("" if contents is None else contents) layout = GridLayout() layout.add_column("main") layout.add_column("vscroll", size=1) @@ -70,9 +70,11 @@ class ScrollView(View): async def watch_y(self, new_value: float) -> None: self.window.scroll_y = round(new_value) self.vscroll.position = round(new_value) + # self.window.require_repaint() + self.window.require_layout() - async def update(self, renderabe: RenderableType) -> None: - await self.window.update(renderabe) + async def update(self, renderable: RenderableType) -> None: + await self.window.update(renderable) async def on_mount(self, event: events.Mount) -> None: assert isinstance(self.layout, GridLayout) @@ -172,19 +174,17 @@ class ScrollView(View): self.animate("x", self.target_x, speed=150, easing="out_cubic") self.animate("y", self.target_y, speed=150, easing="out_cubic") - async def message_page_update(self, message: Message) -> None: + async def message_virtual_size_change(self, message: Message) -> None: + virtual_size = self.window.virtual_size + self.log("VIRTUAL_SIZE", self.size, virtual_size) self.x = self.validate_x(self.x) self.y = self.validate_y(self.y) - self.vscroll.virtual_size = self.window.virtual_size.height + self.vscroll.virtual_size = virtual_size.height self.vscroll.window_size = self.size.height assert isinstance(self.layout, GridLayout) - if self.layout.show_column( - "vscroll", self.window.virtual_size.height > self.size.height - ): + if self.layout.show_column("vscroll", virtual_size.height > self.size.height): self.require_layout() - if self.layout.show_row( - "hscroll", self.window.virtual_size.width > self.size.width - ): + if self.layout.show_row("hscroll", virtual_size.width > self.size.width): self.require_layout() From 8bdbfffe714bd8dc1ef27822639ee56f3466878b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Jul 2021 08:59:56 +0100 Subject: [PATCH 21/36] added old value to watch --- examples/code_viewer.py | 2 +- src/textual/reactive.py | 28 ++++++++++++++++++++-------- src/textual/views/_window_view.py | 2 +- src/textual/widgets/_scroll_view.py | 11 +++++++---- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/examples/code_viewer.py b/examples/code_viewer.py index 9a0175e8e..a89a8bd18 100644 --- a/examples/code_viewer.py +++ b/examples/code_viewer.py @@ -32,7 +32,7 @@ class MyApp(App): await self.view.dock( ScrollView(self.directory), edge="left", size=32, name="sidebar" ) - await self.view.dock(self.body, edge="right") + await self.view.dock(self.body, edge="top") async def message_file_click(self, message: FileClick) -> None: syntax = Syntax.from_path( diff --git a/src/textual/reactive.py b/src/textual/reactive.py index da5fc6f66..9acea0990 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -1,7 +1,7 @@ from __future__ import annotations +import inspect from functools import partial -import sys from typing import ( Any, Awaitable, @@ -27,6 +27,10 @@ if TYPE_CHECKING: ReactiveType = TypeVar("ReactiveType") +def count_params(func: Callable) -> int: + return len(inspect.signature(func).parameters) + + class Reactive(Generic[ReactiveType]): """Reactive descriptor.""" @@ -72,7 +76,7 @@ class Reactive(Generic[ReactiveType]): self._first = False setattr(obj, internal_name, value) - self.check_watchers(obj, name) + self.check_watchers(obj, name, current_value) if self.layout: obj.require_layout() @@ -80,23 +84,29 @@ class Reactive(Generic[ReactiveType]): obj.require_repaint() @classmethod - def check_watchers(cls, obj: Reactable, name: str) -> None: + def check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None: internal_name = f"__{name}" value = getattr(obj, internal_name) async def update_watcher( - obj: Reactable, watch_function: Callable, value + obj: Reactable, watch_function: Callable, old_value: Any, value: Any ) -> None: _rich_traceback_guard = True - await watch_function(value) + if count_params(watch_function) == 2: + await watch_function(old_value, value) + else: + await watch_function(value) await Reactive.compute(obj) watch_function = getattr(obj, f"watch_{name}", None) if callable(watch_function): obj.post_message_no_wait( events.Callback( - obj, callback=partial(update_watcher, obj, watch_function, value) + obj, + callback=partial( + update_watcher, obj, watch_function, old_value, value + ), ) ) @@ -105,7 +115,8 @@ class Reactive(Generic[ReactiveType]): for watcher in watchers: obj.post_message_no_wait( events.Callback( - obj, callback=partial(update_watcher, obj, watcher, value) + obj, + callback=partial(update_watcher, obj, watcher, old_value, value), ) ) @@ -126,8 +137,9 @@ def watch( obj: Reactable, attribute_name: str, callback: Callable[[Any], Awaitable[None]] ) -> None: watcher_name = f"__{attribute_name}_watchers" + current_value = getattr(obj, attribute_name, None) if not hasattr(obj, watcher_name): setattr(obj, watcher_name, set()) watchers = getattr(obj, watcher_name) watchers.add(callback) - Reactive.check_watchers(obj, attribute_name) + Reactive.check_watchers(obj, attribute_name, current_value) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 36dde2da2..9ba580846 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -33,7 +33,7 @@ class WindowView(View, layout=VerticalLayout): layout.clear() layout.add(widget if isinstance(widget, Widget) else Static(widget)) await self.refresh_layout() - self.require_layout() + # self.require_layout() async def watch_virtual_size(self, size: Dimensions) -> None: await self.emit(VirtualSizeChange(self)) diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index bf9066bab..4c1400662 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -66,11 +66,11 @@ class ScrollView(View): async def watch_x(self, new_value: float) -> None: self.window.scroll_x = round(new_value) self.hscroll.position = round(new_value) + self.window.require_layout() async def watch_y(self, new_value: float) -> None: self.window.scroll_y = round(new_value) self.vscroll.position = round(new_value) - # self.window.require_repaint() self.window.require_layout() async def update(self, renderable: RenderableType) -> None: @@ -175,10 +175,13 @@ class ScrollView(View): self.animate("y", self.target_y, speed=150, easing="out_cubic") async def message_virtual_size_change(self, message: Message) -> None: + self.log(self.y) + return virtual_size = self.window.virtual_size - self.log("VIRTUAL_SIZE", self.size, virtual_size) - self.x = self.validate_x(self.x) - self.y = self.validate_y(self.y) + # self.log("VIRTUAL_SIZE", self.size, virtual_size) + # self.x = self.validate_x(self.x) + # self.y = self.validate_y(self.y) + self.log(self.y) self.vscroll.virtual_size = virtual_size.height self.vscroll.window_size = self.size.height From 3edcfdacc0b644596d64898603aa932e56a0c29b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Jul 2021 11:50:32 +0100 Subject: [PATCH 22/36] scroll bug --- CHANGELOG.md | 1 + src/textual/events.py | 6 +-- src/textual/geometry.py | 6 +-- src/textual/layout.py | 7 +-- src/textual/layout_map.py | 6 +-- src/textual/layouts/dock.py | 4 +- src/textual/layouts/grid.py | 14 +++--- src/textual/layouts/vertical.py | 5 ++- src/textual/page.py | 8 ++-- src/textual/view.py | 10 ++--- src/textual/views/_window_view.py | 17 +++++--- src/textual/widget.py | 6 +-- src/textual/widgets/_scroll_view.py | 25 +++++++---- tests/test_geometry.py | 68 ++++++++++++++--------------- 14 files changed, 99 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5070c8d..24b7047c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Simplified events. Remove Startup event (use Mount) +- Changed geometry.Point to geometry.Offset and geometry.Dimensions to geometry.Size ## [0.1.8] - 2021-07-17 diff --git a/src/textual/events.py b/src/textual/events.py index cdb3b9b83..5d5974662 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -5,7 +5,7 @@ from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar import rich.repr from rich.style import Style -from .geometry import Offset, Dimensions +from .geometry import Offset, Size from .message import Message from ._types import MessageTarget from .keys import Keys @@ -106,8 +106,8 @@ class Resize(Event): return isinstance(message, Resize) @property - def size(self) -> Dimensions: - return Dimensions(self.width, self.height) + def size(self) -> Size: + return Size(self.width, self.height) def __rich_repr__(self) -> rich.repr.RichReprResult: yield self.width diff --git a/src/textual/geometry.py b/src/textual/geometry.py index e1749fbf1..85fe35fac 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -65,7 +65,7 @@ class Offset(NamedTuple): return Offset(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor))) -class Dimensions(NamedTuple): +class Size(NamedTuple): """An area defined by its width and height.""" width: int @@ -194,9 +194,9 @@ class Region(NamedTuple): return Offset(self.x, self.y) @property - def size(self) -> Dimensions: + def size(self) -> Size: """Get the size of the region.""" - return Dimensions(self.width, self.height) + return Size(self.width, self.height) @property def corners(self) -> tuple[int, int, int, int]: diff --git a/src/textual/layout.py b/src/textual/layout.py index bf39801ce..1d91bcceb 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -21,7 +21,7 @@ from .layout_map import LayoutMap from ._lines import crop_lines from ._types import Lines -from .geometry import clamp, Region, Offset, Dimensions +from .geometry import clamp, Region, Offset, Size PY38 = sys.version_info >= (3, 8) @@ -106,7 +106,7 @@ class Layout(ABC): map = self.generate_map( console, - Dimensions(width, height), + Size(width, height), Region(0, 0, width, height), scroll, ) @@ -164,7 +164,7 @@ class Layout(ABC): @abstractmethod def generate_map( - self, console: Console, size: Dimensions, viewport: Region, scroll: Offset + self, console: Console, size: Size, viewport: Region, scroll: Offset ) -> LayoutMap: """Generate a layout map that defines where on the screen the widgets will be drawn. @@ -297,6 +297,7 @@ class Layout(ABC): region, clip, lines = region_lines else: lines = render(widget, region.width, region.height) + log("RENDERING", widget) if region in clip: self.renders[widget] = (region, clip, lines) yield region, clip, lines diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index be42e610d..a32c633d9 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -4,7 +4,7 @@ from rich.console import Console from typing import ItemsView, KeysView, ValuesView, NamedTuple -from .geometry import Region, Dimensions +from .geometry import Region, Size from .widget import Widget @@ -16,13 +16,13 @@ class RenderRegion(NamedTuple): class LayoutMap: - def __init__(self, size: Dimensions) -> None: + def __init__(self, size: Size) -> None: self.size = size self.contents_region = Region(0, 0, 0, 0) self.widgets: dict[Widget, RenderRegion] = {} @property - def virtual_size(self) -> Dimensions: + def virtual_size(self) -> Size: return self.contents_region.size def __getitem__(self, widget: Widget) -> RenderRegion: diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 9bbba9cce..8616c1fea 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -8,7 +8,7 @@ from typing import Iterable, TYPE_CHECKING, Sequence from rich.console import Console from .._layout_resolve import layout_resolve -from ..geometry import Offset, Region, Dimensions +from ..geometry import Offset, Region, Size from ..layout import Layout from ..layout_map import LayoutMap @@ -49,7 +49,7 @@ class DockLayout(Layout): yield from dock.widgets def generate_map( - self, console: Console, size: Dimensions, viewport: Region, scroll: Offset + self, console: Console, size: Size, viewport: Region, scroll: Offset ) -> LayoutMap: map: LayoutMap = LayoutMap(size) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 5a1d7c31e..25a7056a9 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -11,7 +11,7 @@ from typing import Iterable, NamedTuple from rich.console import Console from .._layout_resolve import layout_resolve -from ..geometry import Dimensions, Offset, Region +from ..geometry import Size, Offset, Region from ..layout import Layout from ..layout_map import LayoutMap from ..widget import Widget @@ -238,8 +238,8 @@ class GridLayout(Layout): def _align( cls, region: Region, - grid_size: Dimensions, - container: Dimensions, + grid_size: Size, + container: Size, col_align: GridAlign, row_align: GridAlign, ) -> Region: @@ -264,7 +264,7 @@ class GridLayout(Layout): return self.widgets.keys() def generate_map( - self, console: Console, size: Dimensions, viewport: Region, scroll: Offset + self, console: Console, size: Size, viewport: Region, scroll: Offset ) -> LayoutMap: """Generate a map that associates widgets with their location on screen. @@ -338,9 +338,7 @@ class GridLayout(Layout): # ) # map.update(sub_map) - container = Dimensions( - width - self.column_gutter * 2, height - self.row_gutter * 2 - ) + container = Size(width - self.column_gutter * 2, height - self.row_gutter * 2) column_names, column_tracks, column_count, column_size = resolve_tracks( [ options @@ -357,7 +355,7 @@ class GridLayout(Layout): self.row_gap, self.row_repeat, ) - grid_size = Dimensions(column_size, row_size) + grid_size = Size(column_size, row_size) widget_areas = ( (widget, area) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index d0d46d217..d44e7226c 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -5,7 +5,7 @@ from typing import Iterable from rich.console import Console -from ..geometry import Offset, Region, Dimensions +from ..geometry import Offset, Region, Size from ..layout import Layout from ..layout_map import LayoutMap from ..widget import Widget @@ -28,7 +28,7 @@ class VerticalLayout(Layout): return self._widgets def generate_map( - self, console: Console, size: Dimensions, viewport: Region, scroll: Offset + self, console: Console, size: Size, viewport: Region, scroll: Offset ) -> LayoutMap: index = 0 width, height = size @@ -50,6 +50,7 @@ class VerticalLayout(Layout): renderable, console.options.update_width(render_width) ) region = Region(x, y, render_width, len(lines)) + self.renders[widget] = (region - scroll, viewport, lines) add_widget(widget, region - scroll, viewport) else: add_widget( diff --git a/src/textual/page.py b/src/textual/page.py index 6816055c9..b45eb7ec1 100644 --- a/src/textual/page.py +++ b/src/textual/page.py @@ -7,7 +7,7 @@ from rich.padding import Padding, PaddingDimensions from rich.segment import Segment from rich.style import StyleType -from .geometry import Dimensions, Offset +from .geometry import Size, Offset from .message import Message from .widget import Widget, Reactive @@ -38,7 +38,7 @@ class PageRender: self.offset = Offset(0, 0) self._render_width: int | None = None self._render_height: int | None = None - self.size = Dimensions(0, 0) + self.size = Size(0, 0) self._lines: list[list[Segment]] = [] def move_to(self, x: int = 0, y: int = 0) -> None: @@ -61,7 +61,7 @@ class PageRender: if self.padding: renderable = Padding(renderable, self.padding) self._lines[:] = console.render_lines(renderable, options, style=style) - self.size = Dimensions(width, len(self._lines)) + self.size = Size(width, len(self._lines)) self.page.emit_no_wait(PageUpdate(self.page)) def __rich_console__( @@ -126,7 +126,7 @@ class Page(Widget): self.require_repaint() @property - def virtual_size(self) -> Dimensions: + def virtual_size(self) -> Size: return self._page.size def render(self) -> RenderableType: diff --git a/src/textual/view.py b/src/textual/view.py index 358fc109e..b095afd29 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -10,7 +10,7 @@ from rich.style import Style from . import events from . import log from .layout import Layout, NoWidget -from .geometry import Dimensions, Offset, Region +from .geometry import Size, Offset, Region from .messages import UpdateMessage, LayoutMessage from .reactive import Reactive, watch @@ -30,7 +30,7 @@ class View(Widget): self.layout: Layout = layout or self.layout_factory() self.mouse_over: Widget | None = None self.focused: Widget | None = None - self.size = Dimensions(0, 0) + self.size = Size(0, 0) self.widgets: set[Widget] = set() self.named_widgets: dict[str, Widget] = {} self._mouse_style: Style = Style() @@ -56,7 +56,7 @@ class View(Widget): def scroll(self) -> Offset: return Offset(self.scroll_x, self.scroll_y) - virtual_size: Reactive[Dimensions] = Reactive(Dimensions(0, 0)) + virtual_size: Reactive[Size] = Reactive(Size(0, 0)) # @property # def virtual_size(self) -> Dimensions: @@ -160,7 +160,7 @@ class View(Widget): self.console, width, height, self.scroll ) self.virtual_size = self.layout.map.virtual_size - self.app.refresh() + # self.app.refresh() for widget in hidden: widget.post_message_no_wait(events.Hide(self)) @@ -177,7 +177,7 @@ class View(Widget): ) async def on_resize(self, event: events.Resize) -> None: - self.size = Dimensions(event.width, event.height) + self.size = Size(event.width, event.height) await self.refresh_layout() def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 9ba580846..fcc78bd88 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -2,7 +2,8 @@ from __future__ import annotations from rich.console import RenderableType -from ..geometry import Offset, Dimensions +from .. import events +from ..geometry import Offset, Size from ..layouts.vertical import VerticalLayout from ..view import View from ..message import Message @@ -24,16 +25,22 @@ class WindowView(View, layout=VerticalLayout): ) -> None: self.gutter = gutter layout = VerticalLayout() - layout.add(widget if isinstance(widget, Widget) else Static(widget)) + self.widget = widget if isinstance(widget, Widget) else Static(widget) + layout.add(self.widget) super().__init__(name=name, layout=layout) async def update(self, widget: Widget | RenderableType) -> None: layout = self.layout assert isinstance(layout, VerticalLayout) layout.clear() - layout.add(widget if isinstance(widget, Widget) else Static(widget)) + self.widget = widget if isinstance(widget, Widget) else Static(widget) + layout.add(self.widget) await self.refresh_layout() - # self.require_layout() + self.require_layout() - async def watch_virtual_size(self, size: Dimensions) -> None: + async def watch_virtual_size(self, size: Size) -> None: await self.emit(VirtualSizeChange(self)) + + # async def on_resize(self, event: events.Resize) -> None: + # self.layout.renders.pop(self.widget) + # self.require_repaint() diff --git a/src/textual/widget.py b/src/textual/widget.py index d3ceed38e..acf1429fb 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -20,7 +20,7 @@ from rich.style import Style from . import events from ._animator import BoundAnimator from ._context import active_app -from .geometry import Dimensions +from .geometry import Size from .message import Message from .message_pump import MessagePump from .messages import LayoutMessage, UpdateMessage @@ -47,7 +47,7 @@ class Widget(MessagePump): self.name = name or f"{class_name}#{_count}" - self.size = Dimensions(0, 0) + self.size = Size(0, 0) self.size_changed = False self._repaint_required = False self._layout_required = False @@ -175,7 +175,7 @@ class Widget(MessagePump): async def on_event(self, event: events.Event) -> None: if isinstance(event, events.Resize): - new_size = Dimensions(event.width, event.height) + new_size = Size(event.width, event.height) if self.size != new_size: self.size = new_size self.require_repaint() diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 4c1400662..8bb6b12fd 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -9,7 +9,7 @@ from .. import events from ..layouts.grid import GridLayout from ..message import Message from ..scrollbar import ScrollTo, ScrollBar -from ..geometry import clamp, Offset, Dimensions +from ..geometry import clamp, Offset, Size from ..page import Page from ..reactive import watch from ..view import View @@ -52,16 +52,24 @@ class ScrollView(View): target_y: Reactive[float] = Reactive(0, repaint=False) def validate_x(self, value: float) -> float: - return clamp(value, 0, self.window.virtual_size.width - self.size.width) + return clamp(value, 0, self.max_scroll_x) def validate_target_x(self, value: float) -> float: - return clamp(value, 0, self.window.virtual_size.width - self.size.width) + return clamp(value, 0, self.max_scroll_x) def validate_y(self, value: float) -> float: - return clamp(value, 0, self.window.virtual_size.height - self.size.height) + return clamp(value, 0, self.max_scroll_y) def validate_target_y(self, value: float) -> float: - return clamp(value, 0, self.window.virtual_size.height - self.size.height) + return clamp(value, 0, self.max_scroll_y) + + @property + def max_scroll_y(self) -> float: + return max(0, self.window.virtual_size.height - self.size.height) + + @property + def max_scroll_x(self) -> float: + return max(0, self.window.virtual_size.width - self.size.width) async def watch_x(self, new_value: float) -> None: self.window.scroll_x = round(new_value) @@ -175,12 +183,11 @@ class ScrollView(View): self.animate("y", self.target_y, speed=150, easing="out_cubic") async def message_virtual_size_change(self, message: Message) -> None: - self.log(self.y) - return + virtual_size = self.window.virtual_size # self.log("VIRTUAL_SIZE", self.size, virtual_size) - # self.x = self.validate_x(self.x) - # self.y = self.validate_y(self.y) + self.x = self.validate_x(self.x) + self.y = self.validate_y(self.y) self.log(self.y) self.vscroll.virtual_size = virtual_size.height self.vscroll.window_size = self.size.height diff --git a/tests/test_geometry.py b/tests/test_geometry.py index cfe94fc74..71c16317b 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,57 +1,57 @@ import pytest -from textual.geometry import clamp, Offset, Dimensions, Region +from textual.geometry import clamp, Offset, Size, Region def test_dimensions_region(): - assert Dimensions(30, 40).region == Region(0, 0, 30, 40) + assert Size(30, 40).region == Region(0, 0, 30, 40) def test_dimensions_contains(): - assert Dimensions(10, 10).contains(5, 5) - assert Dimensions(10, 10).contains(9, 9) - assert Dimensions(10, 10).contains(0, 0) - assert not Dimensions(10, 10).contains(10, 9) - assert not Dimensions(10, 10).contains(9, 10) - assert not Dimensions(10, 10).contains(-1, 0) - assert not Dimensions(10, 10).contains(0, -1) + assert Size(10, 10).contains(5, 5) + assert Size(10, 10).contains(9, 9) + assert Size(10, 10).contains(0, 0) + assert not Size(10, 10).contains(10, 9) + assert not Size(10, 10).contains(9, 10) + assert not Size(10, 10).contains(-1, 0) + assert not Size(10, 10).contains(0, -1) def test_dimensions_contains_point(): - assert Dimensions(10, 10).contains_point(Offset(5, 5)) - assert Dimensions(10, 10).contains_point(Offset(9, 9)) - assert Dimensions(10, 10).contains_point(Offset(0, 0)) - assert not Dimensions(10, 10).contains_point(Offset(10, 9)) - assert not Dimensions(10, 10).contains_point(Offset(9, 10)) - assert not Dimensions(10, 10).contains_point(Offset(-1, 0)) - assert not Dimensions(10, 10).contains_point(Offset(0, -1)) + assert Size(10, 10).contains_point(Offset(5, 5)) + assert Size(10, 10).contains_point(Offset(9, 9)) + assert Size(10, 10).contains_point(Offset(0, 0)) + assert not Size(10, 10).contains_point(Offset(10, 9)) + assert not Size(10, 10).contains_point(Offset(9, 10)) + assert not Size(10, 10).contains_point(Offset(-1, 0)) + assert not Size(10, 10).contains_point(Offset(0, -1)) def test_dimensions_contains_special(): with pytest.raises(TypeError): - (1, 2, 3) in Dimensions(10, 10) + (1, 2, 3) in Size(10, 10) - assert (5, 5) in Dimensions(10, 10) - assert (9, 9) in Dimensions(10, 10) - assert (0, 0) in Dimensions(10, 10) - assert (10, 9) not in Dimensions(10, 10) - assert (9, 10) not in Dimensions(10, 10) - assert (-1, 0) not in Dimensions(10, 10) - assert (0, -1) not in Dimensions(10, 10) + assert (5, 5) in Size(10, 10) + assert (9, 9) in Size(10, 10) + assert (0, 0) in Size(10, 10) + assert (10, 9) not in Size(10, 10) + assert (9, 10) not in Size(10, 10) + assert (-1, 0) not in Size(10, 10) + assert (0, -1) not in Size(10, 10) def test_dimensions_bool(): - assert Dimensions(1, 1) - assert Dimensions(3, 4) - assert not Dimensions(0, 1) - assert not Dimensions(1, 0) + assert Size(1, 1) + assert Size(3, 4) + assert not Size(0, 1) + assert not Size(1, 0) def test_dimensions_area(): - assert Dimensions(0, 0).area == 0 - assert Dimensions(1, 0).area == 0 - assert Dimensions(1, 1).area == 1 - assert Dimensions(4, 5).area == 20 + assert Size(0, 0).area == 0 + assert Size(1, 0).area == 0 + assert Size(1, 1).area == 1 + assert Size(4, 5).area == 20 def test_clamp(): @@ -97,8 +97,8 @@ def test_region_area(): def test_region_size(): - assert isinstance(Region(3, 4, 5, 6).size, Dimensions) - assert Region(3, 4, 5, 6).size == Dimensions(5, 6) + assert isinstance(Region(3, 4, 5, 6).size, Size) + assert Region(3, 4, 5, 6).size == Size(5, 6) def test_region_origin(): From 2967fc0d8e8cf61303eec803a9e949d853a40df6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Jul 2021 13:15:32 +0100 Subject: [PATCH 23/36] new names and defaults --- src/textual/geometry.py | 18 ++++++++++-------- src/textual/layout.py | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 85fe35fac..025a458c4 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -17,6 +17,8 @@ def clamp(value: T, minimum: T, maximum: T) -> T: Returns: T: New value that is not less than the minimum or greater than the maximum. """ + if minimum > maximum: + maximum, minimum = minimum, maximum if value < minimum: return minimum elif value > maximum: @@ -28,8 +30,8 @@ def clamp(value: T, minimum: T, maximum: T) -> T: class Offset(NamedTuple): """A point defined by x and y coordinates.""" - x: int - y: int + x: int = 0 + y: int = 0 @property def is_origin(self) -> bool: @@ -76,7 +78,7 @@ class Size(NamedTuple): @property def area(self) -> int: - """Get the area of the dimensions. + """Get the area of the size. Returns: int: Area in cells. @@ -90,7 +92,7 @@ class Size(NamedTuple): return Region(0, 0, width, height) def contains(self, x: int, y: int) -> bool: - """Check if a point is in the region. + """Check if a point is in the size. Args: x (int): X coordinate (column) @@ -103,7 +105,7 @@ class Size(NamedTuple): return width > x >= 0 and height > y >= 0 def contains_point(self, point: tuple[int, int]) -> bool: - """Check if a point is in the region. + """Check if a point is in the size. Args: point (tuple[int, int]): A tuple of x and y coordinates. @@ -127,7 +129,7 @@ class Size(NamedTuple): class Region(NamedTuple): - """Defines a rectangular region of the screen.""" + """Defines a rectangular region.""" x: int y: int @@ -176,7 +178,7 @@ class Region(NamedTuple): return (self.y, self.y + self.height) @property - def x_end(self) -> int: + def x_max(self) -> int: return self.x + self.width @property @@ -210,7 +212,7 @@ class Region(NamedTuple): @property def x_range(self) -> range: - return range(self.x, self.x_end) + return range(self.x, self.x_max) @property def y_range(self) -> range: diff --git a/src/textual/layout.py b/src/textual/layout.py index 1d91bcceb..17408045b 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -376,7 +376,7 @@ class Layout(ABC): # first_cut = clamp(render_region.x, clip_x, clip_x2) # last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2) first_cut = render_region.x - last_cut = render_region.x_end + last_cut = render_region.x_max final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] # final_cuts = cuts[y] From e04aaf4ed21ce234918683b43640ce5b62cf3dd8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 2 Aug 2021 09:43:20 +0100 Subject: [PATCH 24/36] layout mechanism --- examples/grid.py | 5 +- src/textual/_linux_driver.py | 5 +- src/textual/app.py | 1 - src/textual/events.py | 24 +++++--- src/textual/layout.py | 95 ++++++++++++++++++----------- src/textual/layout_map.py | 6 +- src/textual/layouts/vertical.py | 39 ++++++------ src/textual/messages.py | 13 +--- src/textual/view.py | 18 +++--- src/textual/views/_window_view.py | 6 +- src/textual/widget.py | 71 ++++++++++++++++++--- src/textual/widgets/_scroll_view.py | 7 +-- src/textual/widgets/_static.py | 1 + 13 files changed, 185 insertions(+), 106 deletions(-) diff --git a/examples/grid.py b/examples/grid.py index c31a26f42..d66ce07b6 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -11,8 +11,8 @@ class GridTest(App): grid = await self.view.dock_grid(edge="left", size=70, name="left") - self.view["left"].scroll_y = 5 - self.view["left"].scroll_x = 5 + # self.view["left"].scroll_y = 5 + # self.view["left"].scroll_x = 5 grid.add_column(fraction=1, name="left", min_size=20) grid.add_column(size=30, name="center") @@ -35,6 +35,7 @@ class GridTest(App): area3=Placeholder(name="area3"), area4=Placeholder(name="area4"), ) + await self.view.update_layout() GridTest.run(title="Grid Test", log="textual.log") diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index c6c0f462e..38ff68bbd 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from . import events from .driver import Driver +from .geometry import Size from ._types import MessageTarget from ._xterm_parser import XTermParser @@ -72,7 +73,7 @@ class LinuxDriver(Driver): def on_terminal_resize(signum, stack) -> None: terminal_size = self._get_terminal_size() width, height = terminal_size - event = events.Resize(self._target, width, height) + event = events.Resize(self._target, Size(width, height)) self.console.size = terminal_size asyncio.run_coroutine_threadsafe( self._target.post_message(event), @@ -115,7 +116,7 @@ class LinuxDriver(Driver): ) width, height = self.console.size = self._get_terminal_size() asyncio.run_coroutine_threadsafe( - self._target.post_message(events.Resize(self._target, width, height)), + self._target.post_message(events.Resize(self._target, Size(width, height))), loop=loop, ) self._key_thread.start() diff --git a/src/textual/app.py b/src/textual/app.py index 14a71d571..012c1663b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -267,7 +267,6 @@ class App(MessagePump): self.title = self._title self.require_layout() await self.animator.start() - await super().process_messages() log("PROCESS END") await self.animator.stop() diff --git a/src/textual/events.py b/src/textual/events.py index 5d5974662..cba368d24 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -10,7 +10,6 @@ from .message import Message from ._types import MessageTarget from .keys import Keys - MouseEventT = TypeVar("MouseEventT", bound="MouseEvent") if TYPE_CHECKING: @@ -53,6 +52,11 @@ class Shutdown(Event): pass +class Repaint(Event, bubble=False): + def can_replace(self, message: "Message") -> bool: + return isinstance(message, Repaint) + + class Load(Event): """ Sent when the App is running but *before* the terminal is in application mode. @@ -87,27 +91,29 @@ class Action(Event, bubble=True): class Resize(Event): """Sent when the app or widget has been resized.""" - __slots__ = ["width", "height"] - width: int - height: int + __slots__ = ["size"] + size: Size - def __init__(self, sender: MessageTarget, width: int, height: int) -> None: + def __init__(self, sender: MessageTarget, size: Size) -> None: """ Args: sender (MessageTarget): Event sender. width (int): New width in terminal cells. height (int): New height in terminal cells. """ - self.width = width - self.height = height + self.size = size super().__init__(sender) def can_replace(self, message: "Message") -> bool: return isinstance(message, Resize) @property - def size(self) -> Size: - return Size(self.width, self.height) + def width(self) -> int: + return self.size.width + + @property + def height(self) -> int: + return self.size.height def __rich_repr__(self) -> rich.repr.RichReprResult: yield self.width diff --git a/src/textual/layout.py b/src/textual/layout.py index 17408045b..ca3ca482c 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -76,7 +76,7 @@ class Layout(ABC): self._layout_map: LayoutMap | None = None self.width = 0 self.height = 0 - self.renders: dict[Widget, tuple[Region, Region, Lines]] = {} + self.regions: dict[Widget, tuple[Region, Region]] = {} self._cuts: list[list[int]] | None = None self._require_update: bool = True self.background = "" @@ -92,9 +92,9 @@ class Layout(ABC): def reset(self) -> None: self._cuts = None - if self._require_update: - self.renders.clear() - self._layout_map = None + # if self._require_update: + # self.regions.clear() + # self._layout_map = None def reflow( self, console: Console, width: int, height: int, scroll: Offset @@ -137,15 +137,9 @@ class Layout(ABC): # Copy renders if the size hasn't changed new_renders = { - widget: (region, clip, self.renders[widget][2]) - for widget, (region, _order, clip) in map.items() - if ( - widget in self.renders - and self.renders[widget][0].size == region.size - and not widget.check_repaint() - ) + widget: (region, clip) for widget, (region, _order, clip) in map.items() } - self.renders = new_renders + self.regions = new_renders # Widgets with changed size resized_widgets = { @@ -216,9 +210,9 @@ class Layout(ABC): widget, region = self.get_widget_at(x, y) except NoWidget: return Style.null() - if widget not in self.renders: + if widget not in self.regions: return Style.null() - _region, clip, lines = self.renders[widget] + lines = widget._get_lines() x -= region.x y -= region.y line = lines[y] @@ -281,39 +275,56 @@ class Layout(ABC): else: widget_regions = [] - def render(widget: Widget, width: int, height: int) -> Lines: - lines = console.render_lines( - widget, console.options.update_dimensions(width, height) - ) - return lines + # def render(widget: Widget, width: int, height: int) -> Lines: + # lines = console.render_lines( + # widget, console.options.update_dimensions(width, height) + # ) + # return lines for widget, region, _order, clip in widget_regions: if not widget.is_visual: continue - region_lines = self.renders.get(widget) - if region_lines is not None: - region, clip, lines = region_lines - else: - lines = render(widget, region.width, region.height) - log("RENDERING", widget) + lines = widget._get_lines() + width, height = region.size + lines = Segment.set_shape(lines, width, height) + # assert Segment.get_shape(lines) == region.size if region in clip: - self.renders[widget] = (region, clip, lines) yield region, clip, lines elif clip.overlaps(region): new_region = region.intersection(clip) delta_x = new_region.x - region.x delta_y = new_region.y - region.y - self.renders[widget] = (region, clip, lines) splits = [delta_x, delta_x + new_region.width] - lines = lines[delta_y : delta_y + new_region.height] - divide = Segment.divide lines = [list(divide(line, splits))[1] for line in lines] yield region, clip, lines + # region_lines = self.regions.get(widget) + + # if region_lines is not None: + # region, clip, lines = region_lines + # else: + # lines = render(widget, region.width, region.height) + # log("RENDERING", widget) + # if region in clip: + # self.regions[widget] = (region, clip, lines) + # yield region, clip, lines + # elif clip.overlaps(region): + # new_region = region.intersection(clip) + # delta_x = new_region.x - region.x + # delta_y = new_region.y - region.y + # self.regions[widget] = (region, clip, lines) + # splits = [delta_x, delta_x + new_region.width] + + # lines = lines[delta_y : delta_y + new_region.height] + + # divide = Segment.divide + # lines = [list(divide(line, splits))[1] for line in lines] + # yield region, clip, lines + @classmethod def _assemble_chops( cls, chops: list[dict[int, list[Segment] | None]] @@ -347,8 +358,6 @@ class Layout(ABC): crop_region = crop or Region(0, 0, self.width, self.height) - # clip_x, clip_y, clip_x2, clip_y2 = clip.corners - divide = Segment.divide # Maps each cut on to a list of segments @@ -411,15 +420,27 @@ class Layout(ABC): yield self.render(console) def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None: - if widget not in self.renders: + + if widget not in self.regions: return None - region, clip, lines = self.renders[widget] - new_lines = console.render_lines( - widget, console.options.update_dimensions(region.width, region.height) - ) + region, clip = self.regions[widget] - self.renders[widget] = (region, clip, new_lines) + if not region.size: + return None + + widget._clear_render_cache() + # if not region or not clip: + # return + + # widget._clear_render_cache() + # widget.render_lines() + + # new_lines = console.render_lines( + # widget, console.options.update_dimensions(region.width, region.height) + # ) + + # self.regions[widget] = (region, clip, new_lines) update_region = region.intersection(clip) update_lines = self.render(console, update_region).lines diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index a32c633d9..22015e828 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -28,13 +28,13 @@ class LayoutMap: def __getitem__(self, widget: Widget) -> RenderRegion: return self.widgets[widget] - def items(self) -> ItemsView: + def items(self) -> ItemsView[Widget, RenderRegion]: return self.widgets.items() - def keys(self) -> KeysView: + def keys(self) -> KeysView[Widget]: return self.widgets.keys() - def values(self) -> ValuesView: + def values(self) -> ValuesView[RenderRegion]: return self.widgets.values() def clear(self) -> None: diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index d44e7226c..981e47ca9 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -4,7 +4,7 @@ from typing import Iterable from rich.console import Console - +from .. import log from ..geometry import Offset, Region, Size from ..layout import Layout from ..layout_map import LayoutMap @@ -42,22 +42,25 @@ class VerticalLayout(Layout): map.add_widget(console, widget, region, (self.z, index), clip) for widget in self._widgets: - try: - region, clip, lines = self.renders[widget] - except KeyError: - renderable = widget.render() - lines = console.render_lines( - renderable, console.options.update_width(render_width) - ) - region = Region(x, y, render_width, len(lines)) - self.renders[widget] = (region - scroll, viewport, lines) - add_widget(widget, region - scroll, viewport) - else: - add_widget( - widget, - Region(x, y, region.width, region.height) - scroll, - clip, - ) - y += region.height + gutter_height + # if widget._render_cache is not None: + # lines = widget._render_cache.lines + # else: + # lines = widget.render_lines(render_width).lines + + region = Region(x, y, render_width, 100) + add_widget(widget, region - scroll, viewport) + + # try: + # region, clip = self.regions[widget] + # except KeyError: + # lines = widget.render_lines(render_width) + # log("***VERTICAL", len(lines)) + # region = Region(x, y, render_width, len(lines)) + # add_widget(widget, region - scroll, viewport) + # else: + # add_widget( + # widget, Region(x, y, region.width, region.height) - scroll, clip + # ) + # y += region.height + gutter_height return map diff --git a/src/textual/messages.py b/src/textual/messages.py index 054882c0b..a8a36bf3a 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -17,28 +17,19 @@ class UpdateMessage(Message): self, sender: MessagePump, widget: Widget, - offset_x: int = 0, - offset_y: int = 0, - reflow: bool = False, ): super().__init__(sender) self.widget = widget - self.offset_x = offset_x - self.offset_y = offset_y - self.reflow = reflow def __rich_repr__(self) -> rich.repr.RichReprResult: yield self.sender yield "widget" - yield "offset_x", self.offset_x, 0 - yield "offset_y", self.offset_y, 0 - yield "reflow", self.reflow, False def can_replace(self, message: Message) -> bool: - return isinstance(message, UpdateMessage) and message.sender == self.sender + return isinstance(message, UpdateMessage) and self.widget is message.widget @rich.repr.auto class LayoutMessage(Message): def can_replace(self, message: Message) -> bool: - return isinstance(message, LayoutMessage) and message.sender == self.sender + return isinstance(message, LayoutMessage) diff --git a/src/textual/view.py b/src/textual/view.py index b095afd29..716e9e807 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -30,7 +30,6 @@ class View(Widget): self.layout: Layout = layout or self.layout_factory() self.mouse_over: Widget | None = None self.focused: Widget | None = None - self.size = Size(0, 0) self.widgets: set[Widget] = set() self.named_widgets: dict[str, Widget] = {} self._mouse_style: Style = Style() @@ -127,6 +126,7 @@ class View(Widget): async def message_update(self, message: UpdateMessage) -> None: widget = message.widget assert isinstance(widget, Widget) + display_update = self.root_view.layout.update_widget(self.console, widget) if display_update is not None: self.app.display(display_update) @@ -159,8 +159,12 @@ class View(Widget): hidden, shown, resized = self.layout.reflow( self.console, width, height, self.scroll ) + assert self.layout.map is not None self.virtual_size = self.layout.map.virtual_size - # self.app.refresh() + # for widget, region in self.layout: + # widget._update_size(region.size) + + self.app.refresh() for widget in hidden: widget.post_message_no_wait(events.Hide(self)) @@ -172,13 +176,13 @@ class View(Widget): for widget, region in self.layout: if widget in send_resize: - widget.post_message_no_wait( - events.Resize(self, region.width, region.height) - ) + widget._update_size(region.size) + widget.post_message_no_wait(events.Resize(self, region.size)) async def on_resize(self, event: events.Resize) -> None: - self.size = Size(event.width, event.height) - await self.refresh_layout() + self._update_size(event.size) + if self.is_root_view: + await self.refresh_layout() def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: return self.layout.get_widget_at(x, y) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index fcc78bd88..7f98fa156 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -41,6 +41,6 @@ class WindowView(View, layout=VerticalLayout): async def watch_virtual_size(self, size: Size) -> None: await self.emit(VirtualSizeChange(self)) - # async def on_resize(self, event: events.Resize) -> None: - # self.layout.renders.pop(self.widget) - # self.require_repaint() + async def on_resize(self, event: events.Resize) -> None: + # self.layout.renders.pop(self.widget) + self.require_repaint() diff --git a/src/textual/widget.py b/src/textual/widget.py index acf1429fb..757848f28 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -7,6 +7,7 @@ from typing import ( TYPE_CHECKING, Callable, ClassVar, + NamedTuple, NewType, cast, ) @@ -15,6 +16,7 @@ from rich.align import Align from rich.console import Console, RenderableType from rich.panel import Panel from rich.pretty import Pretty +from rich.segment import Segment from rich.style import Style from . import events @@ -25,6 +27,7 @@ from .message import Message from .message_pump import MessagePump from .messages import LayoutMessage, UpdateMessage from .reactive import Reactive, watch +from ._types import Lines if TYPE_CHECKING: from .app import App @@ -33,6 +36,11 @@ if TYPE_CHECKING: log = getLogger("rich") +class RenderCache(NamedTuple): + size: Size + lines: Lines + + @rich.repr.auto class Widget(MessagePump): _id: ClassVar[int] = 0 @@ -47,12 +55,12 @@ class Widget(MessagePump): self.name = name or f"{class_name}#{_count}" - self.size = Size(0, 0) - self.size_changed = False + self._size = Size(0, 0) self._repaint_required = False self._layout_required = False self._animate: BoundAnimator | None = None self._reactive_watches: dict[str, Callable] = {} + self._render_cache: RenderCache | None = None self.highlight_style: Style | None = None super().__init__() @@ -83,6 +91,10 @@ class Widget(MessagePump): def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: watch(self, attribute_name, callback) + @property + def size(self) -> Size: + return self._size + @property def is_visual(self) -> bool: return True @@ -109,11 +121,46 @@ class Widget(MessagePump): """Get the layout offset as a tuple.""" return (round(self.layout_offset_x), round(self.layout_offset_y)) + def _update_size(self, size: Size) -> None: + self._size = size + # if self._render_cache and self._render_cache.size != size: + # self.render_lines() + # self.require_repaint() + # self.size = size + + def render_lines(self) -> RenderCache: + width, height = self.size + renderable = self.render() + options = self.console.options.update_dimensions(width, height) + lines = self.console.render_lines(renderable, options) + self._render_cache = RenderCache(self.size, lines) + return self._render_cache + + def _get_lines(self) -> Lines: + """Get render lines for given dimensions. + + Args: + width (int): [description] + height (int): [description] + + Returns: + Lines: [description] + """ + if self._render_cache is None: + self._render_cache = self.render_lines() + lines = self._render_cache.lines + + return lines + + def _clear_render_cache(self) -> None: + self._render_cache = None + def require_repaint(self) -> None: """Mark widget as requiring a repaint. Actual repaint is done by parent on idle. """ + self._render_cache = None self._repaint_required = True self.post_message_no_wait(events.Null(self)) @@ -173,13 +220,15 @@ class Widget(MessagePump): return True return await super().post_message(message) - async def on_event(self, event: events.Event) -> None: - if isinstance(event, events.Resize): - new_size = Size(event.width, event.height) - if self.size != new_size: - self.size = new_size - self.require_repaint() - await super().on_event(event) + # async def on_event(self, event: events.Event) -> None: + # if isinstance(event, events.Resize): + # if self.size != event.size: + # # self.size = event.size + # self.require_repaint() + # await super().on_event(event) + + async def on_resize(self, event: events.Resize) -> None: + self.render_lines() async def on_idle(self, event: events.Idle) -> None: if self.check_layout(): @@ -215,6 +264,10 @@ class Widget(MessagePump): if key_method is not None: await key_method() + # async def on_repaint(self) -> None: + # if self._render_cache is None or self._render_cache.size != self.size: + # self._render_cache = self.render_lines() + async def on_mouse_down(self, event: events.MouseUp) -> None: await self.broker_event("mouse.down", event) diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 8bb6b12fd..c78f5e150 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -158,9 +158,8 @@ class ScrollView(View): self.animate("y", self.target_y, duration=1, easing="out_cubic") async def on_resize(self, event: events.Resize) -> None: - return - if self.fluid: - self.window.update() + + self.window.require_repaint() async def message_scroll_up(self, message: Message) -> None: self.page_up() @@ -185,7 +184,7 @@ class ScrollView(View): async def message_virtual_size_change(self, message: Message) -> None: virtual_size = self.window.virtual_size - # self.log("VIRTUAL_SIZE", self.size, virtual_size) + self.log("VIRTUAL_SIZE", self.size, virtual_size) self.x = self.validate_x(self.x) self.y = self.validate_y(self.y) self.log(self.y) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 9dbdcf4a3..79293419e 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -21,6 +21,7 @@ class Static(Widget): self.padding = padding def render(self) -> RenderableType: + self.log("RENDERING", self.renderable) renderable = self.renderable if self.padding: renderable = Padding(renderable, self.padding) From 79e4a6003ea841fc941401a09e4cfc5feaf682d9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 2 Aug 2021 15:05:34 +0100 Subject: [PATCH 25/36] added verbosity argument --- CHANGELOG.md | 1 + src/textual/__init__.py | 4 ++-- src/textual/app.py | 7 +++++-- src/textual/events.py | 6 +++--- src/textual/layout.py | 17 ++++++++-------- src/textual/layouts/vertical.py | 28 ++++++++------------------- src/textual/message.py | 4 +++- src/textual/message_pump.py | 11 ++++++++++- src/textual/view.py | 12 ++++++++---- src/textual/views/_window_view.py | 5 +++-- src/textual/widget.py | 30 ++++++++++++++++++++--------- src/textual/widgets/_placeholder.py | 1 + src/textual/widgets/_scroll_view.py | 5 ++++- 13 files changed, 78 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b7047c6..9077cffff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added hover over and mouse click to activate keys in footer +- Added verbosity argument to Widget.log ### Changed diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 81000402d..ac5a0cfcc 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -3,11 +3,11 @@ from typing import Any __all__ = ["log", "panic"] -def log(*args: Any) -> None: +def log(*args: Any, verbosity: int = 0) -> None: from ._context import active_app app = active_app.get() - app.log(*args) + app.log(*args, verbosity=verbosity) def panic(*args: Any) -> None: diff --git a/src/textual/app.py b/src/textual/app.py index 012c1663b..49976ab16 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -74,6 +74,7 @@ class App(MessagePump): screen: bool = True, driver_class: Type[Driver] | None = None, log: str = "", + log_verbosity: int = 1, title: str = "Textual Application", ): """The Textual Application base class @@ -108,6 +109,7 @@ class App(MessagePump): self._title = title self.log_file = open(log, "wt") if log else None + self.log_verbosity = log_verbosity self.bindings.bind("ctrl+c", "quit", show=False) @@ -131,9 +133,9 @@ class App(MessagePump): def view(self) -> DockView: return self._view_stack[-1] - def log(self, *args: Any, verbosity: int = 0) -> None: + def log(self, *args: Any, verbosity: int = 1) -> None: try: - if self.log_file: + if self.log_file and verbosity <= self.log_verbosity: output = f" ".join(str(arg) for arg in args) self.log_file.write(output + "\n") self.log_file.flush() @@ -215,6 +217,7 @@ class App(MessagePump): if self.mouse_over is not None: await self.mouse_over.forward_event(events.Leave(self)) if widget is not None: + self.log("FORWARD ENTER", self, widget) await widget.forward_event(events.Enter(self)) finally: self.mouse_over = widget diff --git a/src/textual/events.py b/src/textual/events.py index cba368d24..4ff4236fb 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -23,8 +23,8 @@ class Event(Message): return yield - def __init_subclass__(cls, bubble: bool = False) -> None: - super().__init_subclass__(bubble=bubble) + def __init_subclass__(cls, bubble: bool = False, verbosity: int = 1) -> None: + super().__init_subclass__(bubble=bubble, verbosity=verbosity) class Null(Event): @@ -324,7 +324,7 @@ class MouseEvent(InputEvent): @rich.repr.auto -class MouseMove(MouseEvent): +class MouseMove(MouseEvent, verbosity=3): """Sent when the mouse cursor moves.""" diff --git a/src/textual/layout.py b/src/textual/layout.py index ca3ca482c..915f154e7 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -178,19 +178,19 @@ class Layout(ABC): def map(self) -> LayoutMap | None: return self._layout_map - def __iter__(self) -> Iterator[tuple[Widget, Region]]: + def __iter__(self) -> Iterator[tuple[Widget, Region, Region]]: if self.map is not None: layers = sorted( self.map.widgets.items(), key=lambda item: item[1].order, reverse=True ) for widget, (region, order, clip) in layers: - yield widget, region.intersection(clip) + yield widget, region.intersection(clip), region - def __reversed__(self) -> Iterable[tuple[Widget, Region]]: - if self.map is not None: - layers = sorted(self.map.items(), key=lambda item: item[1].order) - for widget, (region, _order, clip) in layers: - yield widget, region.intersection(clip) + # def __reversed__(self) -> Iterable[tuple[Widget, Region]]: + # if self.map is not None: + # layers = sorted(self.map.items(), key=lambda item: item[1].order) + # for widget, (region, _order, clip) in layers: + # yield widget, region.intersection(clip), region def get_offset(self, widget: Widget) -> Offset: try: @@ -200,7 +200,7 @@ class Layout(ABC): def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under the given point or None.""" - for widget, region in self: + for widget, region, _ in self: if widget.is_visual and region.contains(x, y): return widget, region raise NoWidget(f"No widget under screen coordinate ({x}, {y})") @@ -421,6 +421,7 @@ class Layout(ABC): def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None: + log("UPDATE", widget) if widget not in self.regions: return None diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 981e47ca9..57e26055c 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -32,7 +32,7 @@ class VerticalLayout(Layout): ) -> LayoutMap: index = 0 width, height = size - gutter_width, gutter_height = self.gutter + gutter_height, gutter_width = self.gutter render_width = width - gutter_width * 2 x = gutter_width y = gutter_height @@ -42,25 +42,13 @@ class VerticalLayout(Layout): map.add_widget(console, widget, region, (self.z, index), clip) for widget in self._widgets: - # if widget._render_cache is not None: - # lines = widget._render_cache.lines - # else: - # lines = widget.render_lines(render_width).lines - - region = Region(x, y, render_width, 100) + if ( + not widget.render_cache + or widget.render_cache.size.width != render_width + ): + widget.render_lines_free(render_width) + render_height = widget.render_cache.size.height + region = Region(x, y, render_width, render_height) add_widget(widget, region - scroll, viewport) - # try: - # region, clip = self.regions[widget] - # except KeyError: - # lines = widget.render_lines(render_width) - # log("***VERTICAL", len(lines)) - # region = Region(x, y, render_width, len(lines)) - # add_widget(widget, region - scroll, viewport) - # else: - # add_widget( - # widget, Region(x, y, region.width, region.height) - scroll, clip - # ) - # y += region.height + gutter_height - return map diff --git a/src/textual/message.py b/src/textual/message.py index 87d0da835..9026b05ad 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -21,6 +21,7 @@ class Message: sender: MessageTarget bubble: ClassVar[bool] = False + verbosity: ClassVar[int] = 1 def __init__(self, sender: MessageTarget) -> None: """ @@ -39,9 +40,10 @@ class Message: def __rich_repr__(self) -> rich.repr.RichReprResult: yield self.sender - def __init_subclass__(cls, bubble: bool = False) -> None: + def __init_subclass__(cls, bubble: bool = False, verbosity: int = 1) -> None: super().__init_subclass__() cls.bubble = bubble + cls.verbosity = verbosity def can_replace(self, message: "Message") -> bool: """Check if another message may supersede this one. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index aa08a7f7f..18bec6689 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -32,6 +32,7 @@ class MessagePump: def __init__(self, parent: MessagePump | None = None) -> None: self._message_queue: Queue[Message | None] = Queue() self._parent = parent + self._running: bool = False self._closing: bool = False self._closed: bool = False self._disabled_messages: set[type[Message]] = set() @@ -59,6 +60,10 @@ class MessagePump: def is_parent_active(self): return self._parent and not self._parent._closed and not self._parent._closing + @property + def is_running(self) -> bool: + return self._running + def log(self, *args) -> None: return self.app.log(*args) @@ -159,14 +164,18 @@ class MessagePump: self._task = asyncio.create_task(self.process_messages()) async def process_messages(self) -> None: + self._running = True try: return await self._process_messages() except CancelledError: pass + finally: + self._runnning = False async def _process_messages(self) -> None: """Process messages until the queue is closed.""" _rich_traceback_guard = True + while not self._closed: try: message = await self.get_message() @@ -177,7 +186,7 @@ class MessagePump: except Exception as error: raise error from None - log(message, "->", self) + log(message, ">>>", self, verbosity=message.verbosity) # Combine any pending messages that may supersede this one while not (self._closed or self._closing): pending = self.peek_message() diff --git a/src/textual/view.py b/src/textual/view.py index 716e9e807..5fa646d80 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -109,7 +109,7 @@ class View(Widget): @property def is_root_view(self) -> bool: - return self.parent is self.app + return self._parent and self.parent is self.app def is_mounted(self, widget: Widget) -> bool: return widget in self.widgets @@ -151,6 +151,10 @@ class View(Widget): async def refresh_layout(self) -> None: await self.layout.mount_all(self) + if not self.is_root_view: + await self.app.view.refresh_layout() + return + if not self.size: return @@ -164,7 +168,7 @@ class View(Widget): # for widget, region in self.layout: # widget._update_size(region.size) - self.app.refresh() + # self.app.refresh() for widget in hidden: widget.post_message_no_wait(events.Hide(self)) @@ -174,9 +178,9 @@ class View(Widget): send_resize = shown send_resize.update(resized) - for widget, region in self.layout: + for widget, region, unclipped_region in self.layout: + widget._update_size(unclipped_region.size) if widget in send_resize: - widget._update_size(region.size) widget.post_message_no_wait(events.Resize(self, region.size)) async def on_resize(self, event: events.Resize) -> None: diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 7f98fa156..edb1a03aa 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -20,11 +20,11 @@ class WindowView(View, layout=VerticalLayout): self, widget: RenderableType | Widget, *, - gutter: tuple[int, int] = (1, 1), + gutter: tuple[int, int] = (0, 1), name: str | None = None ) -> None: self.gutter = gutter - layout = VerticalLayout() + layout = VerticalLayout(gutter=gutter) self.widget = widget if isinstance(widget, Widget) else Static(widget) layout.add(self.widget) super().__init__(name=name, layout=layout) @@ -37,6 +37,7 @@ class WindowView(View, layout=VerticalLayout): layout.add(self.widget) await self.refresh_layout() self.require_layout() + await self.emit(VirtualSizeChange(self)) async def watch_virtual_size(self, size: Size) -> None: await self.emit(VirtualSizeChange(self)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 757848f28..1bcd40ac5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -60,7 +60,7 @@ class Widget(MessagePump): self._layout_required = False self._animate: BoundAnimator | None = None self._reactive_watches: dict[str, Callable] = {} - self._render_cache: RenderCache | None = None + self.render_cache: RenderCache | None = None self.highlight_style: Style | None = None super().__init__() @@ -133,8 +133,18 @@ class Widget(MessagePump): renderable = self.render() options = self.console.options.update_dimensions(width, height) lines = self.console.render_lines(renderable, options) - self._render_cache = RenderCache(self.size, lines) - return self._render_cache + self.render_cache = RenderCache(self.size, lines) + return self.render_cache + + def render_lines_free(self, width: int) -> RenderCache: + + renderable = self.render() + + options = self.console.options.update(width=width, height=None) + + lines = self.console.render_lines(renderable, options) + self.render_cache = RenderCache(Size(width, len(lines)), lines) + return self.render_cache def _get_lines(self) -> Lines: """Get render lines for given dimensions. @@ -146,21 +156,20 @@ class Widget(MessagePump): Returns: Lines: [description] """ - if self._render_cache is None: - self._render_cache = self.render_lines() - lines = self._render_cache.lines - + if self.render_cache is None: + self.render_cache = self.render_lines() + lines = self.render_cache.lines return lines def _clear_render_cache(self) -> None: - self._render_cache = None + self.render_cache = None def require_repaint(self) -> None: """Mark widget as requiring a repaint. Actual repaint is done by parent on idle. """ - self._render_cache = None + self.render_cache = None self._repaint_required = True self.post_message_no_wait(events.Null(self)) @@ -218,6 +227,8 @@ class Widget(MessagePump): async def post_message(self, message: Message) -> bool: if not self.check_message_enabled(message): return True + if not self.is_running: + self.log(self, "IS NOT RUNNING") return await super().post_message(message) # async def on_event(self, event: events.Event) -> None: @@ -228,6 +239,7 @@ class Widget(MessagePump): # await super().on_event(event) async def on_resize(self, event: events.Resize) -> None: + self.log("RESIZE", self) self.render_lines() async def on_idle(self, event: events.Idle) -> None: diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 3c7de51c7..17ce9b8c8 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -51,6 +51,7 @@ class Placeholder(Widget, can_focus=True): self.has_focus = False async def on_enter(self, event: events.Enter) -> None: + self.log("ENTER", self) self.mouse_over = True async def on_leave(self, event: events.Leave) -> None: diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index c78f5e150..25d627102 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -8,6 +8,7 @@ from rich.style import StyleType from .. import events from ..layouts.grid import GridLayout from ..message import Message +from ..messages import UpdateMessage from ..scrollbar import ScrollTo, ScrollBar from ..geometry import clamp, Offset, Size from ..page import Page @@ -120,6 +121,9 @@ class ScrollView(View): self.target_x += self.size.width self.animate("x", self.target_x, speed=120, easing="out_cubic") + # async def message_update(self, message: UpdateMessage) -> None: + # self.window.require_layout() + async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: self.scroll_up() @@ -158,7 +162,6 @@ class ScrollView(View): self.animate("y", self.target_y, duration=1, easing="out_cubic") async def on_resize(self, event: events.Resize) -> None: - self.window.require_repaint() async def message_scroll_up(self, message: Message) -> None: From 3e7eb0e6502d519ed09ea2ca43c56f1dc8526bc3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 2 Aug 2021 16:53:46 +0100 Subject: [PATCH 26/36] event verbosity --- examples/grid.py | 2 +- src/textual/app.py | 3 +- src/textual/events.py | 8 ++--- src/textual/layout.py | 14 +-------- src/textual/layout_map.py | 4 +-- src/textual/message_pump.py | 14 ++++++++- src/textual/messages.py | 4 +-- src/textual/reactive.py | 4 +-- src/textual/view.py | 44 ++-------------------------- src/textual/views/_window_view.py | 7 +++-- src/textual/widget.py | 45 +++++++++++------------------ src/textual/widgets/_scroll_view.py | 3 -- src/textual/widgets/_static.py | 2 +- 13 files changed, 52 insertions(+), 102 deletions(-) diff --git a/examples/grid.py b/examples/grid.py index d66ce07b6..771117e11 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -35,7 +35,7 @@ class GridTest(App): area3=Placeholder(name="area3"), area4=Placeholder(name="area4"), ) - await self.view.update_layout() + self.view.refresh(layout=True) GridTest.run(title="Grid Test", log="textual.log") diff --git a/src/textual/app.py b/src/textual/app.py index 49976ab16..48ed24827 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -253,6 +253,7 @@ class App(MessagePump): active_app.set(self) driver = self._driver = self.driver_class(self.console, self) + log("---") log(f"driver={self.driver_class}") await self.dispatch_message(events.Load(sender=self)) @@ -332,7 +333,7 @@ class App(MessagePump): driver.disable_input() await self.close_messages() - def refresh(self) -> None: + def refresh(self, repaint: bool = True, layout: bool = False) -> None: sync_available = os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" if not self._closed: console = self.console diff --git a/src/textual/events.py b/src/textual/events.py index 4ff4236fb..c344696af 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -27,13 +27,13 @@ class Event(Message): super().__init_subclass__(bubble=bubble, verbosity=verbosity) -class Null(Event): +class Null(Event, verbosity=3): def can_replace(self, message: Message) -> bool: return isinstance(message, Null) @rich.repr.auto -class Callback(Event, bubble=False): +class Callback(Event, bubble=False, verbosity=3): def __init__( self, sender: MessageTarget, callback: Callable[[], Awaitable[None]] ) -> None: @@ -88,7 +88,7 @@ class Action(Event, bubble=True): yield "action", self.action -class Resize(Event): +class Resize(Event, verbosity=2): """Sent when the app or widget has been resized.""" __slots__ = ["size"] @@ -360,7 +360,7 @@ class DoubleClick(MouseEvent): @rich.repr.auto -class Timer(Event): +class Timer(Event, verbosity=3): __slots__ = ["time", "count", "callback"] def __init__( diff --git a/src/textual/layout.py b/src/textual/layout.py index 915f154e7..72ab649ba 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -421,7 +421,6 @@ class Layout(ABC): def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None: - log("UPDATE", widget) if widget not in self.regions: return None @@ -430,18 +429,7 @@ class Layout(ABC): if not region.size: return None - widget._clear_render_cache() - # if not region or not clip: - # return - - # widget._clear_render_cache() - # widget.render_lines() - - # new_lines = console.render_lines( - # widget, console.options.update_dimensions(region.width, region.height) - # ) - - # self.regions[widget] = (region, clip, new_lines) + widget.clear_render_cache() update_region = region.intersection(clip) update_lines = self.render(console, update_region).lines diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index 22015e828..a165e5f43 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -58,7 +58,7 @@ class LayoutMap: sub_map = widget.layout.generate_map( console, region.size, clip, widget.scroll ) - for widget, (sub_region, sub_order, sub_clip) in sub_map.items(): + for sub_widget, (sub_region, sub_order, sub_clip) in sub_map.items(): sub_region += region.origin sub_clip = sub_clip.intersection(clip) - self.add_widget(console, widget, sub_region, sub_order, sub_clip) + self.add_widget(console, sub_widget, sub_region, sub_order, sub_clip) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 18bec6689..1947fedc7 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from asyncio import CancelledError from asyncio import Queue, QueueEmpty, Task +import inspect from typing import TYPE_CHECKING, Awaitable, Iterable, Callable from weakref import WeakSet @@ -24,6 +25,10 @@ class NoParent(Exception): pass +class CallbackError(Exception): + pass + + class MessagePumpClosed(Exception): pass @@ -301,4 +306,11 @@ class MessagePump: event.prevent_default() event.stop() if event.callback is not None: - await event.callback() + try: + callback_result = event.callback() + if inspect.isawaitable(callback_result): + await callback_result + except Exception as error: + raise CallbackError( + f"unable to run callback {event.callback!r}; {error}" + ) diff --git a/src/textual/messages.py b/src/textual/messages.py index a8a36bf3a..d9c454212 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: @rich.repr.auto -class UpdateMessage(Message): +class UpdateMessage(Message, verbosity=3): def __init__( self, sender: MessagePump, @@ -30,6 +30,6 @@ class UpdateMessage(Message): @rich.repr.auto -class LayoutMessage(Message): +class LayoutMessage(Message, verbosity=3): def can_replace(self, message: Message) -> bool: return isinstance(message, LayoutMessage) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 9acea0990..8a5895ec2 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -79,9 +79,9 @@ class Reactive(Generic[ReactiveType]): self.check_watchers(obj, name, current_value) if self.layout: - obj.require_layout() + obj.refresh(layout=True) elif self.repaint: - obj.require_repaint() + obj.refresh() @classmethod def check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None: diff --git a/src/textual/view.py b/src/textual/view.py index 5fa646d80..ccc9404be 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -57,40 +57,6 @@ class View(Widget): virtual_size: Reactive[Size] = Reactive(Size(0, 0)) - # @property - # def virtual_size(self) -> Dimensions: - # return self.layout.map.size if self.layout.map else Dimensions(0, 0) - - # virtual_width: Reactive[int | None] = Reactive(None) - # virtual_height: Reactive[int | None] = Reactive(None) - - # @property - # def virtual_size(self) -> Dimensions: - # virtual_width = self.virtual_width - # virtual_height = self.virtual_height - # return Dimensions( - # (virtual_width if virtual_width is not None else self.size.width), - # (virtual_height if virtual_height is not None else self.size.height), - # ) - - # @virtual_size.setter - # def virtual_size(self, size: tuple[int, int]) -> None: - # width, height = size - # self.virtual_width = width - # self.virtual_height = height - - # @property - # def offset(self) -> Point: - # return Point(self.offset_x, self.offset_y) - - # @property - # def viewport(self) -> Region: - # virtual_width = self.virtual_width - # virtual_height = self.virtual_height - # width = virtual_width if virtual_width is not None else self.size.width - # height = virtual_height if virtual_height is not None else self.size.height - # return Region(self.offset_x, self.offset_y, width, height) - def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: @@ -109,7 +75,7 @@ class View(Widget): @property def is_root_view(self) -> bool: - return self._parent and self.parent is self.app + return bool(self._parent and self.parent is self.app) def is_mounted(self, widget: Widget) -> bool: return widget in self.widgets @@ -159,16 +125,12 @@ class View(Widget): return width, height = self.console.size - # virtual_width, virtual_height = self.virtual_size hidden, shown, resized = self.layout.reflow( self.console, width, height, self.scroll ) assert self.layout.map is not None - self.virtual_size = self.layout.map.virtual_size - # for widget, region in self.layout: - # widget._update_size(region.size) - - # self.app.refresh() + # self.virtual_size = self.layout.map.virtual_size + self.log("VIRTUAL_SIZE", self, type(self.layout), self.virtual_size) for widget in hidden: widget.post_message_no_wait(events.Hide(self)) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index edb1a03aa..298d29932 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -40,8 +40,9 @@ class WindowView(View, layout=VerticalLayout): await self.emit(VirtualSizeChange(self)) async def watch_virtual_size(self, size: Size) -> None: + self.log("VIRTUAL SIZE CHAGE") await self.emit(VirtualSizeChange(self)) - async def on_resize(self, event: events.Resize) -> None: - # self.layout.renders.pop(self.widget) - self.require_repaint() + # async def on_resize(self, event: events.Resize) -> None: + # # self.layout.renders.pop(self.widget) + # self.require_repaint() diff --git a/src/textual/widget.py b/src/textual/widget.py index 1bcd40ac5..57d0e689e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -123,10 +123,6 @@ class Widget(MessagePump): def _update_size(self, size: Size) -> None: self._size = size - # if self._render_cache and self._render_cache.size != size: - # self.render_lines() - # self.require_repaint() - # self.size = size def render_lines(self) -> RenderCache: width, height = self.size @@ -161,7 +157,7 @@ class Widget(MessagePump): lines = self.render_cache.lines return lines - def _clear_render_cache(self) -> None: + def clear_render_cache(self) -> None: self.render_cache = None def require_repaint(self) -> None: @@ -199,17 +195,22 @@ class Widget(MessagePump): async def forward_event(self, event: events.Event) -> None: await self.post_message(event) - async def refresh(self) -> None: - """Re-render the window and repaint it.""" - self.require_repaint() - await self.repaint() + def refresh(self, repaint: bool = True, layout: bool = False) -> None: + """Initiate a refresh of the widget. - async def repaint(self) -> None: - """Instructs parent to repaint this widget.""" - await self.emit(UpdateMessage(self, self)) + This method sets an internal flag to perform a refresh, which will be done on the + next idle event. Only one refresh will be done even if this method is called multiple times. - async def update_layout(self) -> None: - await self.emit(LayoutMessage(self)) + Args: + repaint (bool, optional): Repaint the widget (will call render() again). Defaults to True. + layout (bool, optional): Also layout widgets in the view. Defaults to False. + """ + if layout: + self._layout_required = True + elif repaint: + self.clear_render_cache() + self._repaint_required = True + self.post_message_no_wait(events.Null(self)) def render(self) -> RenderableType: """Get renderable for widget. @@ -231,25 +232,17 @@ class Widget(MessagePump): self.log(self, "IS NOT RUNNING") return await super().post_message(message) - # async def on_event(self, event: events.Event) -> None: - # if isinstance(event, events.Resize): - # if self.size != event.size: - # # self.size = event.size - # self.require_repaint() - # await super().on_event(event) - async def on_resize(self, event: events.Resize) -> None: - self.log("RESIZE", self) self.render_lines() async def on_idle(self, event: events.Idle) -> None: if self.check_layout(): self.reset_check_repaint() self.reset_check_layout() - await self.update_layout() + await self.emit(LayoutMessage(self)) elif self.check_repaint(): self.reset_check_repaint() - await self.repaint() + await self.emit(UpdateMessage(self, self)) async def focus(self) -> None: await self.app.set_focus(self) @@ -276,10 +269,6 @@ class Widget(MessagePump): if key_method is not None: await key_method() - # async def on_repaint(self) -> None: - # if self._render_cache is None or self._render_cache.size != self.size: - # self._render_cache = self.render_lines() - async def on_mouse_down(self, event: events.MouseUp) -> None: await self.broker_event("mouse.down", event) diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 25d627102..779fadcc9 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -185,12 +185,9 @@ class ScrollView(View): self.animate("y", self.target_y, speed=150, easing="out_cubic") async def message_virtual_size_change(self, message: Message) -> None: - virtual_size = self.window.virtual_size - self.log("VIRTUAL_SIZE", self.size, virtual_size) self.x = self.validate_x(self.x) self.y = self.validate_y(self.y) - self.log(self.y) self.vscroll.virtual_size = virtual_size.height self.vscroll.window_size = self.size.height diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 79293419e..8c08844be 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -21,7 +21,7 @@ class Static(Widget): self.padding = padding def render(self) -> RenderableType: - self.log("RENDERING", self.renderable) + # self.log("RENDERING", self.renderable) renderable = self.renderable if self.padding: renderable = Padding(renderable, self.padding) From ae6aa78de9e295d587561ad8c4936d2d739288aa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 5 Aug 2021 09:45:35 +0100 Subject: [PATCH 27/36] Refresh dynamics --- poetry.lock | 110 ++++++++++----------- src/textual/_profile.py | 4 +- src/textual/_timer.py | 8 +- src/textual/app.py | 29 +++--- src/textual/events.py | 18 ++-- src/textual/geometry.py | 25 ++--- src/textual/layout.py | 138 ++++++++++++--------------- src/textual/layout_map.py | 1 + src/textual/layouts/vertical.py | 1 + src/textual/message.py | 2 +- src/textual/message_pump.py | 3 +- src/textual/messages.py | 9 +- src/textual/scrollbar.py | 4 +- src/textual/view.py | 16 +++- src/textual/views/_window_view.py | 10 +- src/textual/widget.py | 32 ++++--- src/textual/widgets/_footer.py | 2 +- src/textual/widgets/_header.py | 4 +- src/textual/widgets/_placeholder.py | 2 +- src/textual/widgets/_scroll_view.py | 13 +-- src/textual/widgets/_static.py | 2 +- src/textual/widgets/_tree_control.py | 2 + 22 files changed, 218 insertions(+), 217 deletions(-) diff --git a/poetry.lock b/poetry.lock index 321022a5c..4862ac59b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -177,7 +177,7 @@ license = ["editdistance-s"] [[package]] name = "importlib-metadata" -version = "4.6.1" +version = "4.6.3" description = "Read metadata from Python packages" category = "dev" optional = false @@ -281,7 +281,7 @@ mkdocs = ">=1.1,<2.0" [[package]] name = "mkdocs-material" -version = "7.2.0" +version = "7.2.2" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -377,11 +377,15 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] name = "platformdirs" -version = "2.0.2" +version = "2.2.0" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] [[package]] name = "pluggy" @@ -535,7 +539,7 @@ pyyaml = "*" [[package]] name = "regex" -version = "2021.7.6" +version = "2021.8.3" description = "Alternative regular expression module, to replace re." category = "dev" optional = false @@ -563,7 +567,7 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] type = "git" url = "git@github.com:willmcgugan/rich" reference = "link-id" -resolved_reference = "8ae1d4ad0a36a84485acccd1768f1f2a122f2277" +resolved_reference = "dbeb776c90cc91cfbe5e07487b640cfe1af37dd1" [[package]] name = "six" @@ -599,7 +603,7 @@ python-versions = "*" [[package]] name = "virtualenv" -version = "20.6.0" +version = "20.7.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -615,7 +619,7 @@ six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] name = "watchdog" @@ -759,8 +763,8 @@ identify = [ {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.6.1-py3-none-any.whl", hash = "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e"}, - {file = "importlib_metadata-4.6.1.tar.gz", hash = "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac"}, + {file = "importlib_metadata-4.6.3-py3-none-any.whl", hash = "sha256:51c6635429c77cf1ae634c997ff9e53ca3438b495f10a55ba28594dd69764a8b"}, + {file = "importlib_metadata-4.6.3.tar.gz", hash = "sha256:0645585859e9a6689c523927a5032f2ba5919f1f7d0e84bd4533312320de1ff9"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -823,8 +827,8 @@ mkdocs-autorefs = [ {file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.2.0.tar.gz", hash = "sha256:9f43c5874e119b312a6f369ef363815c11f182b5cdeff4a3426615ebc4664ace"}, - {file = "mkdocs_material-7.2.0-py2.py3-none-any.whl", hash = "sha256:8b3750857e168a9ca20be34890791817090b016248a39be45069fab5343f1dc0"}, + {file = "mkdocs-material-7.2.2.tar.gz", hash = "sha256:4f501e139e2f8546653e7d8777c9b97ca639d03d8c86345a60609864cc5bbb03"}, + {file = "mkdocs_material-7.2.2-py2.py3-none-any.whl", hash = "sha256:76de22213f0e0319b9bddf1bfa86530e93efb4a604e9ddf8f8419f0438572523"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, @@ -876,8 +880,8 @@ pathspec = [ {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] platformdirs = [ - {file = "platformdirs-2.0.2-py2.py3-none-any.whl", hash = "sha256:0b9547541f599d3d242078ae60b927b3e453f0ad52f58b4d4bc3be86aed3ec41"}, - {file = "platformdirs-2.0.2.tar.gz", hash = "sha256:3b00d081227d9037bbbca521a5787796b5ef5000faea1e43fd76f1d44b06fcfa"}, + {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, + {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, @@ -955,47 +959,39 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] regex = [ - {file = "regex-2021.7.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407"}, - {file = "regex-2021.7.6-cp36-cp36m-win32.whl", hash = "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b"}, - {file = "regex-2021.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb"}, - {file = "regex-2021.7.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895"}, - {file = "regex-2021.7.6-cp37-cp37m-win32.whl", hash = "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5"}, - {file = "regex-2021.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f"}, - {file = "regex-2021.7.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068"}, - {file = "regex-2021.7.6-cp38-cp38-win32.whl", hash = "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0"}, - {file = "regex-2021.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4"}, - {file = "regex-2021.7.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3"}, - {file = "regex-2021.7.6-cp39-cp39-win32.whl", hash = "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035"}, - {file = "regex-2021.7.6-cp39-cp39-win_amd64.whl", hash = "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c"}, - {file = "regex-2021.7.6.tar.gz", hash = "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d"}, + {file = "regex-2021.8.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8764a78c5464ac6bde91a8c87dd718c27c1cabb7ed2b4beaf36d3e8e390567f9"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4551728b767f35f86b8e5ec19a363df87450c7376d7419c3cac5b9ceb4bce576"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:577737ec3d4c195c4aef01b757905779a9e9aee608fa1cf0aec16b5576c893d3"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c856ec9b42e5af4fe2d8e75970fcc3a2c15925cbcc6e7a9bcb44583b10b95e80"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3835de96524a7b6869a6c710b26c90e94558c31006e96ca3cf6af6751b27dca1"}, + {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cea56288eeda8b7511d507bbe7790d89ae7049daa5f51ae31a35ae3c05408531"}, + {file = "regex-2021.8.3-cp36-cp36m-win32.whl", hash = "sha256:a4eddbe2a715b2dd3849afbdeacf1cc283160b24e09baf64fa5675f51940419d"}, + {file = "regex-2021.8.3-cp36-cp36m-win_amd64.whl", hash = "sha256:57fece29f7cc55d882fe282d9de52f2f522bb85290555b49394102f3621751ee"}, + {file = "regex-2021.8.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a5c6dbe09aff091adfa8c7cfc1a0e83fdb8021ddb2c183512775a14f1435fe16"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff4a8ad9638b7ca52313d8732f37ecd5fd3c8e3aff10a8ccb93176fd5b3812f6"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b63e3571b24a7959017573b6455e05b675050bbbea69408f35f3cb984ec54363"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fbc20975eee093efa2071de80df7f972b7b35e560b213aafabcec7c0bd00bd8c"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14caacd1853e40103f59571f169704367e79fb78fac3d6d09ac84d9197cadd16"}, + {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb350eb1060591d8e89d6bac4713d41006cd4d479f5e11db334a48ff8999512f"}, + {file = "regex-2021.8.3-cp37-cp37m-win32.whl", hash = "sha256:18fdc51458abc0a974822333bd3a932d4e06ba2a3243e9a1da305668bd62ec6d"}, + {file = "regex-2021.8.3-cp37-cp37m-win_amd64.whl", hash = "sha256:026beb631097a4a3def7299aa5825e05e057de3c6d72b139c37813bfa351274b"}, + {file = "regex-2021.8.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:16d9eaa8c7e91537516c20da37db975f09ac2e7772a0694b245076c6d68f85da"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3905c86cc4ab6d71635d6419a6f8d972cab7c634539bba6053c47354fd04452c"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937b20955806381e08e54bd9d71f83276d1f883264808521b70b33d98e4dec5d"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:28e8af338240b6f39713a34e337c3813047896ace09d51593d6907c66c0708ba"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c09d88a07483231119f5017904db8f60ad67906efac3f1baa31b9b7f7cca281"}, + {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:85f568892422a0e96235eb8ea6c5a41c8ccbf55576a2260c0160800dbd7c4f20"}, + {file = "regex-2021.8.3-cp38-cp38-win32.whl", hash = "sha256:bf6d987edd4a44dd2fa2723fca2790f9442ae4de2c8438e53fcb1befdf5d823a"}, + {file = "regex-2021.8.3-cp38-cp38-win_amd64.whl", hash = "sha256:8fe58d9f6e3d1abf690174fd75800fda9bdc23d2a287e77758dc0e8567e38ce6"}, + {file = "regex-2021.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7976d410e42be9ae7458c1816a416218364e06e162b82e42f7060737e711d9ce"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9569da9e78f0947b249370cb8fadf1015a193c359e7e442ac9ecc585d937f08d"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bbe342c5b2dec5c5223e7c363f291558bc27982ef39ffd6569e8c082bdc83"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f421e3cdd3a273bace013751c345f4ebeef08f05e8c10757533ada360b51a39"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea212df6e5d3f60341aef46401d32fcfded85593af1d82b8b4a7a68cd67fdd6b"}, + {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a3b73390511edd2db2d34ff09aa0b2c08be974c71b4c0505b4a048d5dc128c2b"}, + {file = "regex-2021.8.3-cp39-cp39-win32.whl", hash = "sha256:f35567470ee6dbfb946f069ed5f5615b40edcbb5f1e6e1d3d2b114468d505fc6"}, + {file = "regex-2021.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:bfa6a679410b394600eafd16336b2ce8de43e9b13f7fb9247d84ef5ad2b45e91"}, + {file = "regex-2021.8.3.tar.gz", hash = "sha256:8935937dad2c9b369c3d932b0edbc52a62647c2afb2fafc0c280f14a8bf56a6a"}, ] rich = [] six = [ @@ -1044,8 +1040,8 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] virtualenv = [ - {file = "virtualenv-20.6.0-py2.py3-none-any.whl", hash = "sha256:e4fc84337dce37ba34ef520bf2d4392b392999dbe47df992870dc23230f6b758"}, - {file = "virtualenv-20.6.0.tar.gz", hash = "sha256:51df5d8a2fad5d1b13e088ff38a433475768ff61f202356bb9812c454c20ae45"}, + {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"}, + {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"}, ] watchdog = [ {file = "watchdog-2.1.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9628f3f85375a17614a2ab5eac7665f7f7be8b6b0a2a228e6f6a2e91dd4bfe26"}, diff --git a/src/textual/_profile.py b/src/textual/_profile.py index 6a76b6b61..32db80262 100644 --- a/src/textual/_profile.py +++ b/src/textual/_profile.py @@ -8,6 +8,8 @@ from time import time import contextlib from typing import Generator +from . import log + @contextlib.contextmanager def timer(subject: str = "time") -> Generator[None, None, None]: @@ -16,4 +18,4 @@ def timer(subject: str = "time") -> Generator[None, None, None]: yield elapsed = time() - start elapsed_ms = elapsed * 1000 - print(f"{subject} elapsed {elapsed_ms:.2f}ms") + log(f"{subject} elapsed {elapsed_ms:.2f}ms") diff --git a/src/textual/_timer.py b/src/textual/_timer.py index 15f9d303c..1e8999d9b 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -3,14 +3,14 @@ from __future__ import annotations import weakref from asyncio import CancelledError, Event, TimeoutError, wait_for from time import monotonic -from typing import Awaitable, Callable +from typing import Awaitable, Callable, Union -from rich.repr import RichReprResult, rich_repr +from rich.repr import Result, rich_repr from . import events from ._types import MessageTarget -TimerCallback = Callable[[], Awaitable[None]] +TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]] class EventTargetGone(Exception): @@ -47,7 +47,7 @@ class Timer: if not pause: self._active.set() - def __rich_repr__(self) -> RichReprResult: + def __rich_repr__(self) -> Result: yield self._interval yield "name", self.name yield "repeat", self._repeat, None diff --git a/src/textual/app.py b/src/textual/app.py index 48ed24827..1c30cc440 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -119,7 +119,7 @@ class App(MessagePump): sub_title: Reactive[str] = Reactive("") background: Reactive[str] = Reactive("") - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "title", self.title def __rich__(self) -> RenderableType: @@ -217,7 +217,6 @@ class App(MessagePump): if self.mouse_over is not None: await self.mouse_over.forward_event(events.Leave(self)) if widget is not None: - self.log("FORWARD ENTER", self, widget) await widget.forward_event(events.Enter(self)) finally: self.mouse_over = widget @@ -269,7 +268,7 @@ class App(MessagePump): try: self.title = self._title - self.require_layout() + self.refresh() await self.animator.start() await super().process_messages() log("PROCESS END") @@ -295,11 +294,11 @@ class App(MessagePump): if self.log_file is not None: self.log_file.close() - def require_repaint(self) -> None: - self.refresh() + # def require_repaint(self) -> None: + # self.refresh() - def require_layout(self) -> None: - self.view.require_layout() + # 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)) @@ -307,8 +306,8 @@ class App(MessagePump): events.Callback(self, partial(callback, *args, **kwargs)) ) - async def message_update(self, message: Message) -> None: - self.refresh() + # async def message_update(self, message: Message) -> None: + # self.refresh() def register(self, child: MessagePump, parent: MessagePump) -> bool: if child not in self.children: @@ -334,6 +333,7 @@ class App(MessagePump): await self.close_messages() def refresh(self, repaint: bool = True, layout: bool = False) -> None: + log("APP REFRESH") sync_available = os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" if not self._closed: console = self.console @@ -348,11 +348,17 @@ class App(MessagePump): self.panic() def display(self, renderable: RenderableType) -> None: + sync_available = os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" if not self._closed: console = self.console try: - with console: - console.print(renderable) + # if sync_available: + # console.file.write("\x1bP=1s\x1b\\") + # with console: + console.print(renderable) + # if sync_available: + # console.file.write("\x1bP=2s\x1b\\") + # console.file.flush() except Exception: self.panic() @@ -398,7 +404,6 @@ class App(MessagePump): Args: action (str): Action encoded in a string. """ - self.log(action, default_namespace) target, params = actions.parse(action) if "." in target: destination, action_name = target.split(".", 1) diff --git a/src/textual/events.py b/src/textual/events.py index c344696af..22424d691 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: @rich.repr.auto class Event(Message): - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: return yield @@ -40,7 +40,7 @@ class Callback(Event, bubble=False, verbosity=3): self.callback = callback super().__init__(sender) - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "callback", self.callback @@ -84,7 +84,7 @@ class Action(Event, bubble=True): super().__init__(sender) self.action = action - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "action", self.action @@ -115,7 +115,7 @@ class Resize(Event, verbosity=2): def height(self) -> int: return self.size.height - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield self.width yield self.height @@ -159,7 +159,7 @@ class MouseCapture(Event): super().__init__(sender) self.mouse_position = mouse_position - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield None, self.mouse_position @@ -176,7 +176,7 @@ class MouseRelease(Event): super().__init__(sender) self.mouse_position = mouse_position - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield None, self.mouse_position @@ -200,7 +200,7 @@ class Key(InputEvent, bubble=True): super().__init__(sender) self.key = key.value if isinstance(key, Keys) else key - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "key", self.key @@ -284,7 +284,7 @@ class MouseEvent(InputEvent): ) return new_event - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "x", self.x yield "y", self.y yield "delta_x", self.delta_x, 0 @@ -375,7 +375,7 @@ class Timer(Event, verbosity=3): self.count = count self.callback = callback - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield self.timer.name yield "count", self.count diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 025a458c4..89b1bd4f0 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -341,22 +341,25 @@ class Region(NamedTuple): """Get that covers both regions. Args: - region ([type]): A region that overlaps this region. + region (Region): A region that overlaps this region. Returns: Region: A new region that fits within ``region``. """ - x1, y1, x2, y2 = self.corners - cx1, cy1, cx2, cy2 = region.corners + # Unrolled because this method is used a lot + x1, y1, w1, h1 = self + cx1, cy1, w2, h2 = region + x2 = x1 + w1 + y2 = y1 + h1 + cx2 = cx1 + w2 + cy2 = cy1 + h2 - _clamp = clamp - new_region = Region.from_corners( - _clamp(x1, cx1, cx2), - _clamp(y1, cy1, cy2), - _clamp(x2, cx1, cx2), - _clamp(y2, cy1, cy2), - ) - return new_region + rx1 = cx2 if x1 > cx2 else (cx1 if x1 < cx1 else x1) + ry1 = cy2 if y1 > cy2 else (cy1 if y1 < cy1 else y1) + rx2 = cx2 if x2 > cx2 else (cx1 if x2 < cx1 else x2) + ry2 = cy2 if y2 > cy2 else (cy1 if y2 < cy1 else y2) + + return Region(rx1, ry1, rx2 - rx1, ry2 - ry1) def union(self, region: Region) -> Region: """Get a new region that contains both regions. diff --git a/src/textual/layout.py b/src/textual/layout.py index 72ab649ba..31e6edf7b 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -18,6 +18,7 @@ from rich.style import Style from . import log, panic from ._loop import loop_last from .layout_map import LayoutMap +from ._profile import timer from ._lines import crop_lines from ._types import Lines @@ -96,6 +97,7 @@ class Layout(ABC): # self.regions.clear() # self._layout_map = None + @timer("reflow") def reflow( self, console: Console, width: int, height: int, scroll: Offset ) -> ReflowResult: @@ -104,12 +106,13 @@ class Layout(ABC): self.width = width self.height = height - map = self.generate_map( - console, - Size(width, height), - Region(0, 0, width, height), - scroll, - ) + with timer("generate_map"): + map = self.generate_map( + console, + Size(width, height), + Region(0, 0, width, height), + scroll, + ) self._require_update = False # log(map.widgets) @@ -275,22 +278,16 @@ class Layout(ABC): else: widget_regions = [] - # def render(widget: Widget, width: int, height: int) -> Lines: - # lines = console.render_lines( - # widget, console.options.update_dimensions(width, height) - # ) - # return lines - for widget, region, _order, clip in widget_regions: if not widget.is_visual: continue lines = widget._get_lines() - width, height = region.size - lines = Segment.set_shape(lines, width, height) - # assert Segment.get_shape(lines) == region.size - if region in clip: + # width, height = region.size + # lines = Segment.set_shape(lines, width, height) + + if clip in region: yield region, clip, lines elif clip.overlaps(region): new_region = region.intersection(clip) @@ -302,29 +299,6 @@ class Layout(ABC): lines = [list(divide(line, splits))[1] for line in lines] yield region, clip, lines - # region_lines = self.regions.get(widget) - - # if region_lines is not None: - # region, clip, lines = region_lines - # else: - # lines = render(widget, region.width, region.height) - # log("RENDERING", widget) - # if region in clip: - # self.regions[widget] = (region, clip, lines) - # yield region, clip, lines - # elif clip.overlaps(region): - # new_region = region.intersection(clip) - # delta_x = new_region.x - region.x - # delta_y = new_region.y - region.y - # self.regions[widget] = (region, clip, lines) - # splits = [delta_x, delta_x + new_region.width] - - # lines = lines[delta_y : delta_y + new_region.height] - - # divide = Segment.divide - # lines = [list(divide(line, splits))[1] for line in lines] - # yield region, clip, lines - @classmethod def _assemble_chops( cls, chops: list[dict[int, list[Segment] | None]] @@ -332,15 +306,15 @@ class Layout(ABC): from_iterable = chain.from_iterable for bucket in chops: - yield list( - from_iterable( - line for _, line in sorted(bucket.items()) if line is not None - ) + yield from_iterable( + line for _, line in sorted(bucket.items()) if line is not None ) + @timer("render") def render( self, console: Console, + *, crop: Region = None, ) -> SegmentLines: """Render a layout. @@ -358,7 +332,8 @@ class Layout(ABC): crop_region = crop or Region(0, 0, self.width, self.height) - divide = Segment.divide + _Segment = Segment + divide = _Segment.divide # Maps each cut on to a list of segments cuts = self.cuts @@ -369,50 +344,58 @@ class Layout(ABC): # TODO: Provide an option to update the background background_style = console.get_style(self.background) background_render = [ - [Segment(" " * width, background_style)] for _ in range(height) + [_Segment(" " * width, background_style)] for _ in range(height) ] # Go through all the renders in reverse order and fill buckets with no render - renders = self._get_renders(console) - clip_y, clip_y2 = crop_region.y_extents - for region, clip, lines in chain( - renders, [(screen, screen, background_render)] - ): - # clip = clip.intersection(crop_region) - render_region = region.intersection(clip) - for y, line in enumerate(lines, render_region.y): - if clip_y > y > clip_y2: - continue - # first_cut = clamp(render_region.x, clip_x, clip_x2) - # last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2) - first_cut = render_region.x - last_cut = render_region.x_max - final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - # final_cuts = cuts[y] + with timer("renders"): + renders = list(self._get_renders(console)) - if final_cuts == render_region.y_extents: - cut_segments = [line] - else: - render_x = render_region.x - relative_cuts = [cut - render_x for cut in final_cuts] - _, *cut_segments = divide(line, relative_cuts) - for cut, segments in zip(final_cuts, cut_segments): - if chops[y][cut] is None: - chops[y][cut] = segments + with timer("chops"): + clip_y, clip_y2 = crop_region.y_extents + for region, clip, lines in chain( + renders, [(screen, screen, background_render)] + ): + # clip = clip.intersection(crop_region) + render_region = region.intersection(clip) + for y, line in enumerate(lines, render_region.y): + if clip_y > y > clip_y2: + continue + # first_cut = clamp(render_region.x, clip_x, clip_x2) + # last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2) + first_cut = render_region.x + last_cut = render_region.x_max + final_cuts = [ + cut for cut in cuts[y] if (last_cut >= cut >= first_cut) + ] + # final_cuts = cuts[y] + + # log(final_cuts, render_region.x_extents) + if len(final_cuts) == 2: + cut_segments = [line] + else: + render_x = render_region.x + relative_cuts = [cut - render_x for cut in final_cuts] + _, *cut_segments = divide(line, relative_cuts) + for cut, segments in zip(final_cuts, cut_segments): + if chops[y][cut] is None: + chops[y][cut] = segments # Assemble the cut renders in to lists of segments - output_lines = list(self._assemble_chops(chops)) + crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners + output_lines = self._assemble_chops(chops[crop_y:crop_y2]) def width_view(line: list[Segment]) -> list[Segment]: if line: - div_lines = list(Segment.divide(line, [crop_x, crop_x2])) + div_lines = list(divide(line, [crop_x, crop_x2])) line = div_lines[1] if len(div_lines) > 1 else div_lines[0] return line - if crop is not None: - crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners - output_lines = [width_view(line) for line in output_lines[crop_y:crop_y2]] + if crop is not None and (crop_x, crop_x2) != (0, self.width): + render_lines = [width_view(line) for line in output_lines] + else: + render_lines = list(output_lines) - return SegmentLines(output_lines, new_lines=True) + return SegmentLines(render_lines, new_lines=True) def __rich_console__( self, console: Console, options: ConsoleOptions @@ -420,7 +403,6 @@ class Layout(ABC): yield self.render(console) def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None: - if widget not in self.regions: return None @@ -432,7 +414,7 @@ class Layout(ABC): widget.clear_render_cache() update_region = region.intersection(clip) - update_lines = self.render(console, update_region).lines + update_lines = self.render(console, crop=update_region).lines update = LayoutUpdate(update_lines, update_region.x, update_region.y) return update diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index a165e5f43..aa9488b14 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -58,6 +58,7 @@ class LayoutMap: sub_map = widget.layout.generate_map( console, region.size, clip, widget.scroll ) + widget.virtual_size = sub_map.virtual_size for sub_widget, (sub_region, sub_order, sub_clip) in sub_map.items(): sub_region += region.origin sub_clip = sub_clip.intersection(clip) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 57e26055c..cd778b84c 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -47,6 +47,7 @@ class VerticalLayout(Layout): or widget.render_cache.size.width != render_width ): widget.render_lines_free(render_width) + log("RENDERING") render_height = widget.render_cache.size.height region = Region(x, y, render_width, render_height) add_widget(widget, region - scroll, viewport) diff --git a/src/textual/message.py b/src/textual/message.py index 9026b05ad..63bfb7c9c 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -37,7 +37,7 @@ class Message: self._stop_propagation = False super().__init__() - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield self.sender def __init_subclass__(cls, bubble: bool = False, verbosity: int = 1) -> None: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 1947fedc7..5653885c8 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -191,7 +191,6 @@ class MessagePump: except Exception as error: raise error from None - log(message, ">>>", self, verbosity=message.verbosity) # Combine any pending messages that may supersede this one while not (self._closed or self._closing): pending = self.peek_message() @@ -241,6 +240,7 @@ class MessagePump: _rich_traceback_guard = True for method in self._get_dispatch_methods(f"on_{event.name}", event): + log(event, ">>>", self, verbosity=event.verbosity) await method(event) if event.bubble and self._parent and not event._stop_propagation: @@ -253,6 +253,7 @@ class MessagePump: method = getattr(self, method_name, None) if method is not None: + log(message, ">>>", self, verbosity=message.verbosity) await method(message) if message.bubble and self._parent and not message._stop_propagation: diff --git a/src/textual/messages.py b/src/textual/messages.py index d9c454212..6263fa4e6 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -13,15 +13,12 @@ if TYPE_CHECKING: @rich.repr.auto class UpdateMessage(Message, verbosity=3): - def __init__( - self, - sender: MessagePump, - widget: Widget, - ): + def __init__(self, sender: MessagePump, widget: Widget, layout: bool = False): super().__init__(sender) self.widget = widget + self.layout = layout - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield self.sender yield "widget" diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index cfa69dabc..0dfba622b 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -44,7 +44,7 @@ class ScrollTo(Message, bubble=True): self.y = y super().__init__(sender) - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "x", self.x, None yield "y", self.y, None @@ -186,7 +186,7 @@ class ScrollBar(Widget): mouse_over: Reactive[bool] = Reactive(False) grabbed: Reactive[Offset | None] = Reactive(None) - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "virtual_size", self.virtual_size yield "window_size", self.window_size yield "position", self.position diff --git a/src/textual/view.py b/src/textual/view.py index ccc9404be..078d82b65 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -63,7 +63,7 @@ class View(Widget): return yield - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "name", self.name def __getitem__(self, widget_name: str) -> Widget: @@ -90,15 +90,21 @@ class View(Widget): return super().check_layout() or self.layout.check_update() async def message_update(self, message: UpdateMessage) -> None: + message.stop() widget = message.widget assert isinstance(widget, Widget) + if message.layout: + await self.root_view.refresh_layout() + self.log("LAYOUT") + # await self.app.refresh() display_update = self.root_view.layout.update_widget(self.console, widget) if display_update is not None: self.app.display(display_update) async def message_layout(self, message: LayoutMessage) -> None: await self.root_view.refresh_layout() + self.app.refresh() async def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: @@ -113,7 +119,7 @@ class View(Widget): self.named_widgets[name] = widget self.widgets.add(widget) - self.require_repaint() + self.refresh() async def refresh_layout(self) -> None: await self.layout.mount_all(self) @@ -128,9 +134,9 @@ class View(Widget): hidden, shown, resized = self.layout.reflow( self.console, width, height, self.scroll ) + assert self.layout.map is not None - # self.virtual_size = self.layout.map.virtual_size - self.log("VIRTUAL_SIZE", self, type(self.layout), self.virtual_size) + self.virtual_size = self.layout.map.virtual_size for widget in hidden: widget.post_message_no_wait(events.Hide(self)) @@ -143,7 +149,7 @@ class View(Widget): for widget, region, unclipped_region in self.layout: widget._update_size(unclipped_region.size) if widget in send_resize: - widget.post_message_no_wait(events.Resize(self, region.size)) + widget.post_message_no_wait(events.Resize(self, unclipped_region.size)) async def on_resize(self, event: events.Resize) -> None: self._update_size(event.size) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 298d29932..40f259afe 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -36,13 +36,19 @@ class WindowView(View, layout=VerticalLayout): self.widget = widget if isinstance(widget, Widget) else Static(widget) layout.add(self.widget) await self.refresh_layout() - self.require_layout() + self.refresh(layout=True) await self.emit(VirtualSizeChange(self)) async def watch_virtual_size(self, size: Size) -> None: - self.log("VIRTUAL SIZE CHAGE") + self.log("VIRTUAL SIZE CHANGE") await self.emit(VirtualSizeChange(self)) + async def watch_scroll_x(self, value: int) -> None: + self.refresh(layout=True) + + async def watch_scroll_y(self, value: int) -> None: + self.refresh(layout=True) + # async def on_resize(self, event: events.Resize) -> None: # # self.layout.renders.pop(self.widget) # self.require_repaint() diff --git a/src/textual/widget.py b/src/textual/widget.py index 57d0e689e..480b0bd51 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -82,7 +82,7 @@ class Widget(MessagePump): super().__init_subclass__() cls.can_focus = can_focus - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "name", self.name def __rich__(self) -> RenderableType: @@ -154,24 +154,25 @@ class Widget(MessagePump): """ if self.render_cache is None: self.render_cache = self.render_lines() + self.log("RENDERING", self) lines = self.render_cache.lines return lines def clear_render_cache(self) -> None: self.render_cache = None - def require_repaint(self) -> None: - """Mark widget as requiring a repaint. + # def require_repaint(self) -> None: + # """Mark widget as requiring a repaint. - Actual repaint is done by parent on idle. - """ - self.render_cache = None - self._repaint_required = True - self.post_message_no_wait(events.Null(self)) + # Actual repaint is done by parent on idle. + # """ + # self.render_cache = None + # self._repaint_required = True + # self.post_message_no_wait(events.Null(self)) - def require_layout(self) -> None: - self._layout_required = True - self.post_message_no_wait(events.Null(self)) + # def require_layout(self) -> None: + # self._layout_required = True + # self.post_message_no_wait(events.Null(self)) def check_repaint(self) -> bool: return self._repaint_required @@ -208,7 +209,7 @@ class Widget(MessagePump): if layout: self._layout_required = True elif repaint: - self.clear_render_cache() + # self.clear_render_cache() self._repaint_required = True self.post_message_no_wait(events.Null(self)) @@ -233,16 +234,19 @@ class Widget(MessagePump): return await super().post_message(message) async def on_resize(self, event: events.Resize) -> None: - self.render_lines() + self.refresh() async def on_idle(self, event: events.Idle) -> None: if self.check_layout(): self.reset_check_repaint() self.reset_check_layout() + # await self.emit(UpdateMessage(self, self)) + # await self.emit(UpdateMessage(self, self, layout=False)) await self.emit(LayoutMessage(self)) elif self.check_repaint(): + self.render_cache = None self.reset_check_repaint() - await self.emit(UpdateMessage(self, self)) + await self.emit(UpdateMessage(self, self, layout=False)) async def focus(self) -> None: await self.app.set_focus(self) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 0bf9c3e79..88eafac44 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -32,7 +32,7 @@ class Footer(Widget): """Clear any highlight when the mouse leave the widget""" self.highlight_key = None - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "keys", self.keys def make_key_text(self) -> Text: diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index dd52e3d23..15a027d34 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -3,7 +3,7 @@ from logging import getLogger from rich.console import Console, ConsoleOptions, RenderableType from rich.panel import Panel -from rich.repr import rich_repr, RichReprResult +from rich.repr import rich_repr, Result from rich.style import StyleType from rich.table import Table from rich.text import TextType @@ -38,7 +38,7 @@ class Header(Widget): def full_title(self) -> str: return f"{self.title} - {self.sub_title}" if self.sub_title else self.title - def __rich_repr__(self) -> RichReprResult: + def __rich_repr__(self) -> Result: yield self.title async def watch_tall(self, tall: bool) -> None: diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 17ce9b8c8..bed7b5846 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -27,7 +27,7 @@ class Placeholder(Widget, can_focus=True): super().__init__(name=name) self.height = height - def __rich_repr__(self) -> rich.repr.RichReprResult: + def __rich_repr__(self) -> rich.repr.Result: yield "name", self.name yield "has_focus", self.has_focus, False yield "mouse_over", self.mouse_over, False diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 779fadcc9..49cea82e4 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -75,12 +75,10 @@ class ScrollView(View): async def watch_x(self, new_value: float) -> None: self.window.scroll_x = round(new_value) self.hscroll.position = round(new_value) - self.window.require_layout() async def watch_y(self, new_value: float) -> None: self.window.scroll_y = round(new_value) self.vscroll.position = round(new_value) - self.window.require_layout() async def update(self, renderable: RenderableType) -> None: await self.window.update(renderable) @@ -121,9 +119,6 @@ class ScrollView(View): self.target_x += self.size.width self.animate("x", self.target_x, speed=120, easing="out_cubic") - # async def message_update(self, message: UpdateMessage) -> None: - # self.window.require_layout() - async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: self.scroll_up() @@ -161,8 +156,8 @@ class ScrollView(View): self.animate("x", self.target_x, duration=1, easing="out_cubic") self.animate("y", self.target_y, duration=1, easing="out_cubic") - async def on_resize(self, event: events.Resize) -> None: - self.window.require_repaint() + # async def on_resize(self, event: events.Resize) -> None: + # self.window.refresh() async def message_scroll_up(self, message: Message) -> None: self.page_up() @@ -194,6 +189,6 @@ class ScrollView(View): assert isinstance(self.layout, GridLayout) if self.layout.show_column("vscroll", virtual_size.height > self.size.height): - self.require_layout() + self.refresh() if self.layout.show_row("hscroll", virtual_size.width > self.size.width): - self.require_layout() + self.refresh() diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 8c08844be..79293419e 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -21,7 +21,7 @@ class Static(Widget): self.padding = padding def render(self) -> RenderableType: - # self.log("RENDERING", self.renderable) + self.log("RENDERING", self.renderable) renderable = self.renderable if self.padding: renderable = Padding(renderable, self.padding) diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index 7212c3b09..0f2e2c130 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -10,6 +10,7 @@ from rich.text import Text, TextType from rich.tree import Tree from rich.padding import Padding, PaddingDimensions +from .. import log from ..reactive import Reactive from .._types import MessageTarget from ..widget import Widget @@ -125,6 +126,7 @@ class TreeControl(Generic[NodeDataType], Widget): self.require_repaint() def render(self) -> RenderableType: + log("RENDERING TREE", self) return Padding(self._tree, self.padding) def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType: From 6618f0d27269a4f80be424844c130345267e6d0a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 5 Aug 2021 15:53:41 +0100 Subject: [PATCH 28/36] Fix for blanking bug --- examples/code_viewer.py | 37 +++++++--- src/textual/app.py | 7 -- src/textual/geometry.py | 4 +- src/textual/layout.py | 99 ++++++++++---------------- src/textual/layouts/vertical.py | 2 +- src/textual/view.py | 2 + src/textual/views/_window_view.py | 17 +++-- src/textual/widget.py | 19 +---- src/textual/widgets/_directory_tree.py | 2 +- src/textual/widgets/_scroll_view.py | 11 ++- src/textual/widgets/_static.py | 3 +- src/textual/widgets/_tree_control.py | 6 +- 12 files changed, 93 insertions(+), 116 deletions(-) diff --git a/examples/code_viewer.py b/examples/code_viewer.py index a89a8bd18..690001b32 100644 --- a/examples/code_viewer.py +++ b/examples/code_viewer.py @@ -1,7 +1,9 @@ import os import sys +from rich.console import RenderableType from rich.syntax import Syntax +from rich.traceback import Traceback from textual import events from textual.app import App @@ -12,9 +14,13 @@ class MyApp(App): """An example of a very simple Textual App""" async def on_load(self, event: events.Load) -> None: + """Sent before going in to application mode.""" + + # Bind our basic keys await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar") await self.bind("q", "quit", "Quit") + # Get path to show try: self.path = sys.argv[1] except IndexError: @@ -23,28 +29,43 @@ class MyApp(App): ) async def on_mount(self, event: events.Mount) -> None: + """Call after terminal goes in to application mode""" + # Create our widgets + # In this a scroll view for the code and a directory tree self.body = ScrollView() self.directory = DirectoryTree(self.path, "Code") + # Dock our widgets await self.view.dock(Header(), edge="top") await self.view.dock(Footer(), edge="bottom") + + # Note the directory is also in a scroll view await self.view.dock( ScrollView(self.directory), edge="left", size=32, name="sidebar" ) await self.view.dock(self.body, edge="top") async def message_file_click(self, message: FileClick) -> None: - syntax = Syntax.from_path( - message.path, - line_numbers=True, - word_wrap=True, - indent_guides=True, - theme="monokai", - ) + """A message sent by the directory tree when a file is clicked.""" + + syntax: RenderableType + try: + # Construct a Syntax object for the path in the message + syntax = Syntax.from_path( + message.path, + line_numbers=True, + word_wrap=True, + indent_guides=True, + theme="monokai", + ) + except Exception: + # Possibly a binary file + # For demonstration purposes we will show the traceback + syntax = Traceback(theme="monokai", width=None, show_locals=True) self.app.sub_title = os.path.basename(message.path) await self.body.update(syntax) - self.body.home() +# Run our app class MyApp.run(title="Code Viewer", log="textual.log") diff --git a/src/textual/app.py b/src/textual/app.py index 1c30cc440..67a541430 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -333,7 +333,6 @@ class App(MessagePump): await self.close_messages() def refresh(self, repaint: bool = True, layout: bool = False) -> None: - log("APP REFRESH") sync_available = os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" if not self._closed: console = self.console @@ -352,13 +351,7 @@ class App(MessagePump): if not self._closed: console = self.console try: - # if sync_available: - # console.file.write("\x1bP=1s\x1b\\") - # with console: console.print(renderable) - # if sync_available: - # console.file.write("\x1bP=2s\x1b\\") - # console.file.flush() except Exception: self.panic() diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 89b1bd4f0..bc5a4c237 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -244,8 +244,8 @@ class Region(NamedTuple): x, y, x2, y2 = self.corners ox, oy, ox2, oy2 = other.corners - return ((x2 > ox >= x) or (x2 > ox2 > x) or (ox < x and ox2 > x2)) and ( - (y2 > oy >= y) or (y2 > oy2 > y) or (oy < y and oy2 > y2) + return ((x2 > ox >= x) or (x2 > ox2 >= x) or (ox < x and ox2 >= x2)) and ( + (y2 > oy >= y) or (y2 > oy2 >= y) or (oy < y and oy2 >= y2) ) def contains(self, x: int, y: int) -> bool: diff --git a/src/textual/layout.py b/src/textual/layout.py index 31e6edf7b..f74647ada 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -87,6 +87,8 @@ class Layout(ABC): def require_update(self) -> None: self._require_update = True + self.reset() + self._layout_map = None def reset_update(self) -> None: self._require_update = False @@ -97,7 +99,6 @@ class Layout(ABC): # self.regions.clear() # self._layout_map = None - @timer("reflow") def reflow( self, console: Console, width: int, height: int, scroll: Offset ) -> ReflowResult: @@ -106,29 +107,14 @@ class Layout(ABC): self.width = width self.height = height - with timer("generate_map"): - map = self.generate_map( - console, - Size(width, height), - Region(0, 0, width, height), - scroll, - ) + map = self.generate_map( + console, + Size(width, height), + Region(0, 0, width, height), + scroll, + ) self._require_update = False - # log(map.widgets) - # map = { - # widget: OrderedRegion(region + offset, order) - # for widget, (region, order, offset) in map.items() - # } - - # Filter out widgets that are off screen or zero area - - # map = { - # widget: map_region - # for widget, map_region in map.items() - # if map_region.region and viewport.overlaps(map_region.region) - # } - old_widgets = set() if self.map is None else set(self.map.keys()) new_widgets = set(map.keys()) # Newly visible widgets @@ -189,12 +175,6 @@ class Layout(ABC): for widget, (region, order, clip) in layers: yield widget, region.intersection(clip), region - # def __reversed__(self) -> Iterable[tuple[Widget, Region]]: - # if self.map is not None: - # layers = sorted(self.map.items(), key=lambda item: item[1].order) - # for widget, (region, _order, clip) in layers: - # yield widget, region.intersection(clip), region - def get_offset(self, widget: Widget) -> Offset: try: return self.map[widget].region.origin @@ -203,8 +183,8 @@ class Layout(ABC): def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under the given point or None.""" - for widget, region, _ in self: - if widget.is_visual and region.contains(x, y): + for widget, cropped_region, region in self: + if widget.is_visual and cropped_region.contains(x, y): return widget, region raise NoWidget(f"No widget under screen coordinate ({x}, {y})") @@ -284,8 +264,6 @@ class Layout(ABC): continue lines = widget._get_lines() - # width, height = region.size - # lines = Segment.set_shape(lines, width, height) if clip in region: yield region, clip, lines @@ -310,7 +288,6 @@ class Layout(ABC): line for _, line in sorted(bucket.items()) if line is not None ) - @timer("render") def render( self, console: Console, @@ -347,38 +324,34 @@ class Layout(ABC): [_Segment(" " * width, background_style)] for _ in range(height) ] # Go through all the renders in reverse order and fill buckets with no render - with timer("renders"): - renders = list(self._get_renders(console)) + renders = list(self._get_renders(console)) - with timer("chops"): - clip_y, clip_y2 = crop_region.y_extents - for region, clip, lines in chain( - renders, [(screen, screen, background_render)] - ): - # clip = clip.intersection(crop_region) - render_region = region.intersection(clip) - for y, line in enumerate(lines, render_region.y): - if clip_y > y > clip_y2: - continue - # first_cut = clamp(render_region.x, clip_x, clip_x2) - # last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2) - first_cut = render_region.x - last_cut = render_region.x_max - final_cuts = [ - cut for cut in cuts[y] if (last_cut >= cut >= first_cut) - ] - # final_cuts = cuts[y] + clip_y, clip_y2 = crop_region.y_extents + for region, clip, lines in chain( + renders, [(screen, screen, background_render)] + ): + # clip = clip.intersection(crop_region) + render_region = region.intersection(clip) + for y, line in enumerate(lines, render_region.y): + if clip_y > y > clip_y2: + continue + # first_cut = clamp(render_region.x, clip_x, clip_x2) + # last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2) + first_cut = render_region.x + last_cut = render_region.x_max + final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] + # final_cuts = cuts[y] - # log(final_cuts, render_region.x_extents) - if len(final_cuts) == 2: - cut_segments = [line] - else: - render_x = render_region.x - relative_cuts = [cut - render_x for cut in final_cuts] - _, *cut_segments = divide(line, relative_cuts) - for cut, segments in zip(final_cuts, cut_segments): - if chops[y][cut] is None: - chops[y][cut] = segments + # log(final_cuts, render_region.x_extents) + if len(final_cuts) == 2: + cut_segments = [line] + else: + render_x = render_region.x + relative_cuts = [cut - render_x for cut in final_cuts] + _, *cut_segments = divide(line, relative_cuts) + for cut, segments in zip(final_cuts, cut_segments): + if chops[y][cut] is None: + chops[y][cut] = segments # Assemble the cut renders in to lists of segments crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index cd778b84c..c128d7e55 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -47,7 +47,7 @@ class VerticalLayout(Layout): or widget.render_cache.size.width != render_width ): widget.render_lines_free(render_width) - log("RENDERING") + assert widget.render_cache is not None render_height = widget.render_cache.size.height region = Region(x, y, render_width, render_height) add_widget(widget, region - scroll, viewport) diff --git a/src/textual/view.py b/src/textual/view.py index 078d82b65..b235f2c47 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -155,6 +155,8 @@ class View(Widget): self._update_size(event.size) if self.is_root_view: await self.refresh_layout() + self.app.refresh() + event.stop() def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: return self.layout.get_widget_at(x, y) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 40f259afe..64e1afed2 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -7,11 +7,12 @@ from ..geometry import Offset, Size from ..layouts.vertical import VerticalLayout from ..view import View from ..message import Message +from ..messages import UpdateMessage from ..widget import Widget from ..widgets import Static -class VirtualSizeChange(Message): +class WindowChange(Message): pass @@ -37,11 +38,11 @@ class WindowView(View, layout=VerticalLayout): layout.add(self.widget) await self.refresh_layout() self.refresh(layout=True) - await self.emit(VirtualSizeChange(self)) + await self.emit(WindowChange(self)) async def watch_virtual_size(self, size: Size) -> None: self.log("VIRTUAL SIZE CHANGE") - await self.emit(VirtualSizeChange(self)) + await self.emit(WindowChange(self)) async def watch_scroll_x(self, value: int) -> None: self.refresh(layout=True) @@ -49,6 +50,10 @@ class WindowView(View, layout=VerticalLayout): async def watch_scroll_y(self, value: int) -> None: self.refresh(layout=True) - # async def on_resize(self, event: events.Resize) -> None: - # # self.layout.renders.pop(self.widget) - # self.require_repaint() + async def message_update(self, message: UpdateMessage) -> None: + self.layout.require_update() + await self.root_view.refresh_layout() + # self.app.refresh() + + async def on_resize(self, event: events.Resize) -> None: + await self.emit(WindowChange(self)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 480b0bd51..f94ac6454 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -154,26 +154,12 @@ class Widget(MessagePump): """ if self.render_cache is None: self.render_cache = self.render_lines() - self.log("RENDERING", self) lines = self.render_cache.lines return lines def clear_render_cache(self) -> None: self.render_cache = None - # def require_repaint(self) -> None: - # """Mark widget as requiring a repaint. - - # Actual repaint is done by parent on idle. - # """ - # self.render_cache = None - # self._repaint_required = True - # self.post_message_no_wait(events.Null(self)) - - # def require_layout(self) -> None: - # self._layout_required = True - # self.post_message_no_wait(events.Null(self)) - def check_repaint(self) -> bool: return self._repaint_required @@ -207,9 +193,10 @@ class Widget(MessagePump): layout (bool, optional): Also layout widgets in the view. Defaults to False. """ if layout: + self.clear_render_cache() self._layout_required = True elif repaint: - # self.clear_render_cache() + self.clear_render_cache() self._repaint_required = True self.post_message_no_wait(events.Null(self)) @@ -240,8 +227,6 @@ class Widget(MessagePump): if self.check_layout(): self.reset_check_repaint() self.reset_check_layout() - # await self.emit(UpdateMessage(self, self)) - # await self.emit(UpdateMessage(self, self, layout=False)) await self.emit(LayoutMessage(self)) elif self.check_repaint(): self.render_cache = None diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index d21f05aa7..cf2b87f72 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -74,7 +74,7 @@ class DirectoryTree(TreeControl[DirEntry]): await node.add(entry.name, DirEntry(entry.path, entry.is_dir())) node.loaded = True await node.expand() - self.require_repaint() + # self.refresh(layout=True) async def message_tree_click(self, message: TreeClick[DirEntry]) -> None: dir_entry = message.node.data diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 49cea82e4..dcee85e64 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -43,7 +43,7 @@ class ScrollView(View): content="main,main", vscroll="vscroll,main", hscroll="main,hscroll" ) layout.show_row("hscroll", False) - layout.show_row("vscroll", False) + layout.show_column("vscroll", False) super().__init__(name=name, layout=layout) x: Reactive[float] = Reactive(0, repaint=False) @@ -80,7 +80,9 @@ class ScrollView(View): self.window.scroll_y = round(new_value) self.vscroll.position = round(new_value) - async def update(self, renderable: RenderableType) -> None: + async def update(self, renderable: RenderableType, home: bool = True) -> None: + if home: + self.home() await self.window.update(renderable) async def on_mount(self, event: events.Mount) -> None: @@ -156,9 +158,6 @@ class ScrollView(View): self.animate("x", self.target_x, duration=1, easing="out_cubic") self.animate("y", self.target_y, duration=1, easing="out_cubic") - # async def on_resize(self, event: events.Resize) -> None: - # self.window.refresh() - async def message_scroll_up(self, message: Message) -> None: self.page_up() @@ -179,7 +178,7 @@ class ScrollView(View): self.animate("x", self.target_x, speed=150, easing="out_cubic") self.animate("y", self.target_y, speed=150, easing="out_cubic") - async def message_virtual_size_change(self, message: Message) -> None: + async def message_window_change(self, message: Message) -> None: virtual_size = self.window.virtual_size self.x = self.validate_x(self.x) self.y = self.validate_y(self.y) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 79293419e..148a60074 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -21,7 +21,6 @@ class Static(Widget): self.padding = padding def render(self) -> RenderableType: - self.log("RENDERING", self.renderable) renderable = self.renderable if self.padding: renderable = Padding(renderable, self.padding) @@ -29,4 +28,4 @@ class Static(Widget): async def update(self, renderable: RenderableType) -> None: self.renderable = renderable - self.require_repaint() + self.refresh() diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index 0f2e2c130..ed2a794cc 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -65,13 +65,14 @@ class TreeNode(Generic[NodeDataType]): async def expand(self, expanded: bool = True) -> None: self._expanded = expanded self._tree.expanded = expanded - self._control.require_repaint() + self._control.refresh() async def toggle(self) -> None: await self.expand(not self._expanded) async def add(self, label: TextType, data: NodeDataType) -> None: await self._control.add(self._node_id, label, data=data) + self._control.refresh() self._empty = False def __rich__(self) -> RenderableType: @@ -123,10 +124,9 @@ class TreeControl(Generic[NodeDataType], Widget): child_tree.label = child_node self.nodes[self._node_id] = child_node - self.require_repaint() + self.refresh() def render(self) -> RenderableType: - log("RENDERING TREE", self) return Padding(self._tree, self.padding) def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType: From 3aacac6f234706d22054cacebf88728bac04411f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 5 Aug 2021 16:07:03 +0100 Subject: [PATCH 29/36] refresh hover node --- src/textual/widgets/_directory_tree.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index cf2b87f72..d82cfd2b6 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -41,6 +41,7 @@ class DirectoryTree(TreeControl[DirEntry]): node.tree.guide_style = ( "bold not dim red" if node.id == hover_node else "black" ) + self.refresh(layout=True) def render_node(self, node: TreeNode[DirEntry]) -> RenderableType: meta = {"@click": f"click_label({node.id})", "tree_node": node.id} From 2ded0fa54b82446a1217d792cc4aad715fd4d249 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 Aug 2021 11:54:20 +0100 Subject: [PATCH 30/36] ws --- README.md | 128 ++++++++++++++++-- .../messages_and_events/color_changer.py | 2 +- docs/examples/widgets/placeholders.py | 16 +++ examples/README.md | 8 +- examples/grid.py | 3 - examples/grid_auto.py | 2 +- examples/simple.py | 6 + imgs/widgets.png | Bin 0 -> 240071 bytes src/textual/app.py | 15 +- src/textual/events.py | 4 +- src/textual/reactive.py | 7 +- src/textual/view.py | 1 + src/textual/views/_window_view.py | 2 - src/textual/widgets/_placeholder.py | 1 - 14 files changed, 158 insertions(+), 37 deletions(-) create mode 100644 docs/examples/widgets/placeholders.py create mode 100644 imgs/widgets.png diff --git a/README.md b/README.md index 3c69b1876..1aa8202bb 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,121 @@ # Textual -Textual is a TUI (Text User Interface) framework for Python using [Rich](https://github.com/willmcgugan/rich) as a renderer. +Textual is a TUI (Text User Interface) framework for Python using [Rich](https://github.com/willmcgugan/rich) as a renderer. Currently a work in progress, but usable by brave souls who don't mind some API instability between updates. -The end goal is to be able to rapidly create *rich* terminal applications that look as good as possible (within the restrictions imposed by a terminal emulator). - -Rich TUI will integrate tightly with its parent project, Rich. Any of the existing *renderables* can be used in a more dynamic application. +The end goal is to be able to rapidly create _rich_ terminal applications that look as good as possible (within the restrictions imposed by a terminal emulator). Textual will be eventually be cross platform, but for now it is MacOS / Linux only. Windows support is in the pipeline. -This project is currently a work in progress and may not be usable for a while. Follow [@willmcgugan](https://twitter.com/willmcgugan) for progress updates, or post in Discussions if you have any requests / suggestions. +Follow [@willmcgugan](https://twitter.com/willmcgugan) for progress updates, or post in Discussions if you have any requests / suggestions. ![screenshot](./imgs/rich-tui.png) +## How it works -## Updates +Textual has far more in common with web development than with curses. Every component has at its core a _message pump_ where it can receive and process events, a system modelled after JS in the browser. Web developers will recognize timers, intervals, propagation etc. -I'll be documenting progress in video form. +Textual borrows other technologies from the web development world; layout is done with CSS grid and (soon) the theme may be customized with CSS. Textual is also influenced by modern JS frameworks such as Vue and React where modifying the state will automatically update the display. + +## Installation + +You can install Textual via pip (`pip install textual`), or by checking out the repo and installing with [poetry](https://python-poetry.org/docs/). + +``` +poetry install +``` + +## Building Textual applications + +Let's look at the simplest Textual app which does _something_: + +```python +from textual.app import App + + +class Beeper(App): + async def on_key(self, event): + self.console.bell() + + +Beeper.run() +``` + +Here we can see a textual app with a single `on_key` method which will receive key events. Any key event will result in playing a beep noise. Hit ctrl+C to exit. + +Event handlers in Textual are defined by convention, not by inheritance (so you won't find an `on_key` method in the base class). Each event has a `name` attribute which for the key event is simply `"key"`. Textual will call the method named `on_` if it exists. + +Lets look at a _slightly_ more interesting example: + +```python +from textual.app import App + + +class ColorChanger(App): + async def on_key(self, event): + if event.key.isdigit(): + self.background = f"on color({event.key})" + + +ColorChanger.run(log="textual.log") +``` + +This example also handles key events, and will set `App.background` if the key is a digit. So pressing the keys 0 to 9 will change the background color to the corresponding [ansi colors](https://rich.readthedocs.io/en/latest/appendix/colors.html). + +Note that we didn't need to explicitly refresh the screen or draw anything. Setting the `background` attribute is enough for Textual to update the visuals. This is an example of _reactivity_ in Textual. + +### Widgets + +To make more interesting apps you will need to make use of _widgets_, which are independent user interface elements. Textual comes with a (growing) library of widgets, but you can also develop your own. + +Let's look at an app which contains widgets. We will be using the built in `Placeholder` widget which you can use to design application layouts before you implement the real content. They are also very useful for testing. + +```python +from textual import events +from textual.app import App +from textual.widgets import Placeholder + + +class SimpleApp(App): + + async def on_mount(self, event: events.Mount) -> None: + await self.view.dock(Placeholder(), edge="left", size=40) + await self.view.dock(Placeholder(), Placeholder(), edge="top") + + +SimpleApp.run(log="textual.log") +``` + +This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events. We can use it for initializing things. In this case we are going to call `self.view.dock` to add widgets to the interface. More about the `view` object later. + +Here's the first line in the mount handler:: + +```python +await self.view.dock(Placeholder(), edge="left", size=40) +``` + +Note it is asynchronous like almost all API methods in Textual. We are awaiting `self.view.dock` which takes a newly constructed Placeholder widget, and docks it on to the `"left"` edge of the terminal with a size of 40 characters. In a real app you might use this to display a side-bar of sorts. + +The following line is similar: + +```python +await self.view.dock(Placeholder(), Placeholder(), edge="top") +``` + +You will notice that this time we are docking two Placeholder objects on the top edge. We haven't set an explicit size this time, so Textual will divide the remaining size amongst the two new widgets. + +The last line calls the `run` class method in the usual way, but with an argument we haven't seen before: `log="textual.log"` tells Textual to write log information to the given file. You can tail textual.log to see the events that are being processed and other debug information. + +If you run the above example, you will see something like the following: + +![widgets](./imgs/widgets.png) + +If you move the mouse over the terminal you will notice that widgets receive mouse events. You can click any of the placeholders to give it input focus. + +The dock layout feature is good enough for most purposes. For more sophisticated layouts we can use the grid API. See the [calculator.py](https://github.com/willmcgugan/textual/blob/main/examples/calculator.py) example which makes use of Grid. + +## Developer VLog + +Since Textual is a visual medium, I'll be documenting new features and milestones here. ### Update 1 - Basic scrolling @@ -31,7 +131,7 @@ I'll be documenting progress in video form. ### Update 4 - Animation system with easing function -Now with a system to animate a value to another value. Here applied to the scroll position. The animation system supports CSS like *easing functions*. You may be able to tell from the video that the page up / down keys cause the window to first speed up and then slow down. +Now with a system to animate a value to another value. Here applied to the scroll position. The animation system supports CSS like _easing functions_. You may be able to tell from the video that the page up / down keys cause the window to first speed up and then slow down. [![Textual update 4](https://yt-embed.herokuapp.com/embed?v=k2VwOp1YbSk&img=0)](http://www.youtube.com/watch?v=k2VwOp1YbSk) @@ -47,10 +147,18 @@ New version (0.1.4) with API updates and the new layout system. [![Textual update 6](https://yt-embed.herokuapp.com/embed?v=jddccDuVd3E&img=0)](http://www.youtube.com/watch?v=jddccDuVd3E) - ### Update 7 - New Grid Layout + **11 July 2021** -Added a new layout system modelled on CSS grid. The example demostrates how once created a grid will adapt to the available space. +Added a new layout system modelled on CSS grid. The example demonstrates how once created a grid will adapt to the available space. [![Textual update 7](https://yt-embed.herokuapp.com/embed?v=Zh9CEvu73jc&img=0)](http://www.youtube.com/watch?v=Zh9CEvu73jc) + +## Update 8 - Tree control and scroll views + +**6 Aug 2021** + +Added a tree control and refactored the renderer to allow for widgets within a scrollable veiew + +[![Textual update 8](https://yt-embed.herokuapp.com/embed?v=J-dzzD6NQJ4&img=0)](http://www.youtube.com/watch?v=J-dzzD6NQJ4) diff --git a/docs/examples/messages_and_events/color_changer.py b/docs/examples/messages_and_events/color_changer.py index a3d4e6b84..8507b2336 100644 --- a/docs/examples/messages_and_events/color_changer.py +++ b/docs/examples/messages_and_events/color_changer.py @@ -7,4 +7,4 @@ class ColorChanger(App): self.background = f"on color({event.key})" -ColorChanger.run() +ColorChanger.run(log="textual.log") diff --git a/docs/examples/widgets/placeholders.py b/docs/examples/widgets/placeholders.py new file mode 100644 index 000000000..28406add0 --- /dev/null +++ b/docs/examples/widgets/placeholders.py @@ -0,0 +1,16 @@ +from textual import events +from textual.app import App +from textual.widgets import Placeholder + + +class SimpleApp(App): + """Demonstrates smooth animation""" + + async def on_mount(self, event: events.Mount) -> None: + """Build layout here.""" + + await self.view.dock(Placeholder(), edge="left", size=40) + await self.view.dock(Placeholder(), Placeholder(), edge="top") + + +SimpleApp.run(log="textual.log") diff --git a/examples/README.md b/examples/README.md index 9583a061c..bf3295ec9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,9 @@ # Examples -Run any of these examples to demonstrate a features. +Run any of these examples to demonstrate a Textual features. -These examples may not be feature complete, but they should be somewhat useful and a good starting point for your own code. +The example code will generate a log file called "textual.log". Tail this file to gain insight in to what Textual is doing. + +``` +tail -f textual +``` diff --git a/examples/grid.py b/examples/grid.py index 771117e11..8dbb59550 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -11,9 +11,6 @@ class GridTest(App): grid = await self.view.dock_grid(edge="left", size=70, name="left") - # self.view["left"].scroll_y = 5 - # self.view["left"].scroll_x = 5 - grid.add_column(fraction=1, name="left", min_size=20) grid.add_column(size=30, name="center") grid.add_column(fraction=1, name="right") diff --git a/examples/grid_auto.py b/examples/grid_auto.py index 3c59437b7..26a360e2c 100644 --- a/examples/grid_auto.py +++ b/examples/grid_auto.py @@ -22,4 +22,4 @@ class GridTest(App): grid.place(*(Placeholder() for _ in range(20)), center=Placeholder()) -GridTest.run(title="Grid Test") +GridTest.run(title="Grid Test", log="textual.log") diff --git a/examples/simple.py b/examples/simple.py index f5d647312..7f2a250bf 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -9,16 +9,22 @@ class MyApp(App): """An example of a very simple Textual App""" async def on_load(self, event: events.Load) -> None: + """Bind keys with the app loads (but before entering application mode)""" await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar") await self.bind("q", "quit", "Quit") async def on_mount(self, event: events.Mount) -> None: + """Create and dock the widgets.""" + # A scrollview to contain the markdown file body = ScrollView() + # Header / footer / dock await self.view.dock(Header(), edge="top") await self.view.dock(Footer(), edge="bottom") await self.view.dock(Placeholder(), edge="left", size=30, name="sidebar") + + # Dock the body in the remaining space await self.view.dock(body, edge="right") async def get_markdown(filename: str) -> None: diff --git a/imgs/widgets.png b/imgs/widgets.png new file mode 100644 index 0000000000000000000000000000000000000000..bca927bbc6483ed68c057d671210efad7bcf24b6 GIT binary patch literal 240071 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 literal 0 HcmV?d00001 diff --git a/src/textual/app.py b/src/textual/app.py index 67a541430..2e43a0b68 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -112,12 +112,13 @@ class App(MessagePump): self.log_verbosity = log_verbosity self.bindings.bind("ctrl+c", "quit", show=False) + self._refresh_required = False super().__init__() title: Reactive[str] = Reactive("Textual") sub_title: Reactive[str] = Reactive("") - background: Reactive[str] = Reactive("") + background: Reactive[str] = Reactive("black") def __rich_repr__(self) -> rich.repr.Result: yield "title", self.title @@ -294,21 +295,11 @@ class App(MessagePump): if self.log_file is not None: self.log_file.close() - # def require_repaint(self) -> None: - # self.refresh() - - # 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() - def register(self, child: MessagePump, parent: MessagePump) -> bool: if child not in self.children: self.children.add(child) @@ -342,7 +333,7 @@ class App(MessagePump): console.print(Screen(Control.home(), self.view, Control.home())) if sync_available: console.file.write("\x1bP=2s\x1b\\") - console.file.flush() + console.file.flush() except Exception: self.panic() diff --git a/src/textual/events.py b/src/textual/events.py index 22424d691..d60d4f3e3 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -35,7 +35,9 @@ class Null(Event, verbosity=3): @rich.repr.auto class Callback(Event, bubble=False, verbosity=3): def __init__( - self, sender: MessageTarget, callback: Callable[[], Awaitable[None]] + self, + sender: MessageTarget, + callback: Callable[[], Awaitable[None]] ) -> None: self.callback = callback super().__init__(sender) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 8a5895ec2..7ae603207 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -13,6 +13,7 @@ from typing import ( TYPE_CHECKING, ) +from . import log from . import events from ._types import MessageTarget @@ -66,16 +67,14 @@ class Reactive(Generic[ReactiveType]): def __set__(self, obj: Reactable, value: ReactiveType) -> None: name = self.name - internal_name = f"__{name}" - current_value = getattr(obj, internal_name, None) + current_value = getattr(obj, self.internal_name, None) validate_function = getattr(obj, f"validate_{name}", None) if callable(validate_function): value = validate_function(value) if current_value != value or self._first: self._first = False - setattr(obj, internal_name, value) - + setattr(obj, self.internal_name, value) self.check_watchers(obj, name, current_value) if self.layout: diff --git a/src/textual/view.py b/src/textual/view.py index b235f2c47..6f24ed0cd 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -47,6 +47,7 @@ class View(Widget): async def watch_background(self, value: str) -> None: self.layout.background = value + self.app.refresh() scroll_x: Reactive[int] = Reactive(0) scroll_y: Reactive[int] = Reactive(0) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 64e1afed2..f44cff150 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -41,7 +41,6 @@ class WindowView(View, layout=VerticalLayout): await self.emit(WindowChange(self)) async def watch_virtual_size(self, size: Size) -> None: - self.log("VIRTUAL SIZE CHANGE") await self.emit(WindowChange(self)) async def watch_scroll_x(self, value: int) -> None: @@ -53,7 +52,6 @@ class WindowView(View, layout=VerticalLayout): async def message_update(self, message: UpdateMessage) -> None: self.layout.require_update() await self.root_view.refresh_layout() - # self.app.refresh() async def on_resize(self, event: events.Resize) -> None: await self.emit(WindowChange(self)) diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index bed7b5846..cf99ae554 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -51,7 +51,6 @@ class Placeholder(Widget, can_focus=True): self.has_focus = False async def on_enter(self, event: events.Enter) -> None: - self.log("ENTER", self) self.mouse_over = True async def on_leave(self, event: events.Leave) -> None: From 07f95f96dec0d4d3c62464553a6f15e781eec06b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 Aug 2021 12:36:03 +0100 Subject: [PATCH 31/36] version bump --- README.md | 30 ++++++++++++++++++++++++++---- examples/calculator.py | 4 +++- examples/vertical.py | 19 ------------------- pyproject.toml | 2 +- src/textual/views/_window_view.py | 6 ++---- 5 files changed, 32 insertions(+), 29 deletions(-) delete mode 100644 examples/vertical.py diff --git a/README.md b/README.md index 1aa8202bb..393ff0112 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,14 @@ You can install Textual via pip (`pip install textual`), or by checking out the poetry install ``` +## Examples + +Until I've written the documentation, the examples are the best way to learn Textual. Run any of the Python files in [examples](https://github.com/willmcgugan/textual/tree/main/examples) and read the code to see how it works. + ## Building Textual applications +_This guide is a work in progress_ + Let's look at the simplest Textual app which does _something_: ```python @@ -59,15 +65,15 @@ class ColorChanger(App): ColorChanger.run(log="textual.log") ``` -This example also handles key events, and will set `App.background` if the key is a digit. So pressing the keys 0 to 9 will change the background color to the corresponding [ansi colors](https://rich.readthedocs.io/en/latest/appendix/colors.html). +This example also handles key events, and will set `App.background` if the key is a digit. So pressing the keys 0 to 9 will change the background color to the corresponding [ansi color](https://rich.readthedocs.io/en/latest/appendix/colors.html). -Note that we didn't need to explicitly refresh the screen or draw anything. Setting the `background` attribute is enough for Textual to update the visuals. This is an example of _reactivity_ in Textual. +Note that we didn't need to explicitly refresh the screen or draw anything. Setting the `background` attribute is enough for Textual to update the visuals. This is an example of _reactivity_ in Textual. To make changes to the terminal interface you modify the _state_ and let Textual update the visuals. -### Widgets +## Widgets To make more interesting apps you will need to make use of _widgets_, which are independent user interface elements. Textual comes with a (growing) library of widgets, but you can also develop your own. -Let's look at an app which contains widgets. We will be using the built in `Placeholder` widget which you can use to design application layouts before you implement the real content. They are also very useful for testing. +Let's look at an app which contains widgets. We will be using the built in `Placeholder` widget which you can use to design application layouts before you implement the real content. They are very useful for testing. ```python from textual import events @@ -113,6 +119,22 @@ If you move the mouse over the terminal you will notice that widgets receive mou The dock layout feature is good enough for most purposes. For more sophisticated layouts we can use the grid API. See the [calculator.py](https://github.com/willmcgugan/textual/blob/main/examples/calculator.py) example which makes use of Grid. +### Creating Widgets + +_TODO_ + +### Actions + +_TODO_ + +### Events + +_TODO_ + +### Timers and Intervals + +_TODO_ + ## Developer VLog Since Textual is a visual medium, I'll be documenting new features and milestones here. diff --git a/examples/calculator.py b/examples/calculator.py index 474b2f4c1..411477304 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -161,6 +161,7 @@ class Calculator(GridView): def do_math() -> None: """Does the math: LEFT OPERATOR RIGHT""" + self.log(self.left, self.operator, self.right) try: if self.operator == "+": self.left += self.right @@ -172,7 +173,8 @@ class Calculator(GridView): self.left *= self.right self.display = str(self.left) self.value = "" - except ZeroDivisionError: + self.log("=", self.left) + except Exception: self.display = "Error" if button_name.isdigit(): diff --git a/examples/vertical.py b/examples/vertical.py deleted file mode 100644 index 2f43a4f33..000000000 --- a/examples/vertical.py +++ /dev/null @@ -1,19 +0,0 @@ -from textual import events -from textual.app import App - -from textual.views import WindowView -from textual.widgets import Placeholder - - -class MyApp(App): - async def on_mount(self, event: events.Mount) -> None: - window1 = WindowView(Placeholder(height=20)) - # window2 = WindowView(Placeholder(height=20)) - - # window1.scroll_x = -10 - # window1.scroll_y = 5 - - await self.view.dock(window1, edge="left") - - -MyApp.run(log="textual.log") diff --git a/pyproject.toml b/pyproject.toml index 0221da8e8..2c7a41a8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.1.8" +version = "0.1.9" homepage = "https://github.com/willmcgugan/textual" description = "Text User Interface using Rich" authors = ["Will McGugan "] diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index f44cff150..710109ac1 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -44,14 +44,12 @@ class WindowView(View, layout=VerticalLayout): await self.emit(WindowChange(self)) async def watch_scroll_x(self, value: int) -> None: + self.layout.require_update() self.refresh(layout=True) async def watch_scroll_y(self, value: int) -> None: - self.refresh(layout=True) - - async def message_update(self, message: UpdateMessage) -> None: self.layout.require_update() - await self.root_view.refresh_layout() + self.refresh(layout=True) async def on_resize(self, event: events.Resize) -> None: await self.emit(WindowChange(self)) From 52f006a36dbb58e5d1a8369b7c700036c7497a7c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 Aug 2021 12:43:33 +0100 Subject: [PATCH 32/36] requirements --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2c7a41a8c..8c8de9bc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.7" -#rich = "^10.6.0" -rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"} +rich = "^10.7.0" +#rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"} typing-extensions = { version = "^3.10.0", python = "<3.8" } [tool.poetry.dev-dependencies] From 18c61b737b5dbfce040fb1cf84f36e1d861f866c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 Aug 2021 12:54:13 +0100 Subject: [PATCH 33/36] geometry fix --- src/textual/geometry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index bc5a4c237..7743890c6 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -244,8 +244,8 @@ class Region(NamedTuple): x, y, x2, y2 = self.corners ox, oy, ox2, oy2 = other.corners - return ((x2 > ox >= x) or (x2 > ox2 >= x) or (ox < x and ox2 >= x2)) and ( - (y2 > oy >= y) or (y2 > oy2 >= y) or (oy < y and oy2 >= y2) + return ((x2 > ox >= x) or (x2 > ox2 > x) or (ox < x and ox2 >= x2)) and ( + (y2 > oy >= y) or (y2 > oy2 > y) or (oy < y and oy2 >= y2) ) def contains(self, x: int, y: int) -> bool: From 9a217923d5e5110ee9d1a4f1c816273f276cabef Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 Aug 2021 12:57:35 +0100 Subject: [PATCH 34/36] examples --- examples/grid.py | 4 +--- examples/grid_auto.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/grid.py b/examples/grid.py index 8dbb59550..6b07c8f39 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -4,10 +4,9 @@ from textual.widgets import Placeholder class GridTest(App): - async def on_load(self, event: events.Load) -> None: - await self.bind("q,ctrl+c", "quit", "Quit") async def on_mount(self, event: events.Mount) -> None: + """Make a simple grid arrangement.""" grid = await self.view.dock_grid(edge="left", size=70, name="left") @@ -32,7 +31,6 @@ class GridTest(App): area3=Placeholder(name="area3"), area4=Placeholder(name="area4"), ) - self.view.refresh(layout=True) GridTest.run(title="Grid Test", log="textual.log") diff --git a/examples/grid_auto.py b/examples/grid_auto.py index 26a360e2c..71eb550b2 100644 --- a/examples/grid_auto.py +++ b/examples/grid_auto.py @@ -6,10 +6,9 @@ from textual.layouts.grid import GridLayout class GridTest(App): - async def on_load(self, event: events.Load) -> None: - await self.bind("q,ctrl+c", "quit", "Quit") async def on_mount(self, event: events.Mount) -> None: + """Create a grid with auto-arranging cells.""" grid = await self.view.dock_grid() From c66d6724aa255f3e65a0e91ddddd835123d92194 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 Aug 2021 13:00:03 +0100 Subject: [PATCH 35/36] lockfile --- poetry.lock | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4862ac59b..7eb87fa39 100644 --- a/poetry.lock +++ b/poetry.lock @@ -166,7 +166,7 @@ dev = ["twine", "markdown", "flake8"] [[package]] name = "identify" -version = "2.2.11" +version = "2.2.12" description = "File identification library for Python" category = "dev" optional = false @@ -547,28 +547,21 @@ python-versions = "*" [[package]] name = "rich" -version = "10.6.0" +version = "10.7.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false -python-versions = "^3.6" -develop = false +python-versions = ">=3.6,<4.0" [package.dependencies] -colorama = "^0.4.0" -commonmark = "^0.9.0" -pygments = "^2.6.0" -typing-extensions = {version = "^3.7.4", markers = "python_version < \"3.8\""} +colorama = ">=0.4.0,<0.5.0" +commonmark = ">=0.9.0,<0.10.0" +pygments = ">=2.6.0,<3.0.0" +typing-extensions = {version = ">=3.7.4,<4.0.0", markers = "python_version < \"3.8\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] -[package.source] -type = "git" -url = "git@github.com:willmcgugan/rich" -reference = "link-id" -resolved_reference = "dbeb776c90cc91cfbe5e07487b640cfe1af37dd1" - [[package]] name = "six" version = "1.16.0" @@ -647,7 +640,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "89e70da124ff666d5f911585eb2032d523499bcfe3c0efad9b2f5367cc64183b" +content-hash = "a561498403eccf382232162785fccae98ca23115b35849a7dd7331a338d8d7a4" [metadata.files] appdirs = [ @@ -759,8 +752,8 @@ ghp-import = [ {file = "ghp-import-2.0.1.tar.gz", hash = "sha256:753de2eace6e0f7d4edfb3cce5e3c3b98cd52aadb80163303d1d036bda7b4483"}, ] identify = [ - {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, - {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, + {file = "identify-2.2.12-py2.py3-none-any.whl", hash = "sha256:a510cbe155f39665625c8a4c4b4f9360cbce539f51f23f47836ab7dd852db541"}, + {file = "identify-2.2.12.tar.gz", hash = "sha256:242332b3bdd45a8af1752d5d5a3afb12bee26f8e67c4be06e394f82d05ef1a4d"}, ] importlib-metadata = [ {file = "importlib_metadata-4.6.3-py3-none-any.whl", hash = "sha256:51c6635429c77cf1ae634c997ff9e53ca3438b495f10a55ba28594dd69764a8b"}, @@ -993,7 +986,10 @@ regex = [ {file = "regex-2021.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:bfa6a679410b394600eafd16336b2ce8de43e9b13f7fb9247d84ef5ad2b45e91"}, {file = "regex-2021.8.3.tar.gz", hash = "sha256:8935937dad2c9b369c3d932b0edbc52a62647c2afb2fafc0c280f14a8bf56a6a"}, ] -rich = [] +rich = [ + {file = "rich-10.7.0-py3-none-any.whl", hash = "sha256:517b4e0efd064dd1fe821ca93dd3095d73380ceac1f0a07173d507d9b18f1396"}, + {file = "rich-10.7.0.tar.gz", hash = "sha256:13ac80676e12cf528dc4228dc682c8402f82577c2aa67191e294350fa2c3c4e9"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, From 564a40800410f93bad3cf6b1df2c7e32b06d309a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 Aug 2021 13:02:11 +0100 Subject: [PATCH 36/36] black --- examples/grid.py | 1 - examples/grid_auto.py | 1 - src/textual/events.py | 4 +--- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/grid.py b/examples/grid.py index 6b07c8f39..a8bd2ee31 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -4,7 +4,6 @@ from textual.widgets import Placeholder class GridTest(App): - async def on_mount(self, event: events.Mount) -> None: """Make a simple grid arrangement.""" diff --git a/examples/grid_auto.py b/examples/grid_auto.py index 71eb550b2..5cd1901fe 100644 --- a/examples/grid_auto.py +++ b/examples/grid_auto.py @@ -6,7 +6,6 @@ from textual.layouts.grid import GridLayout class GridTest(App): - async def on_mount(self, event: events.Mount) -> None: """Create a grid with auto-arranging cells.""" diff --git a/src/textual/events.py b/src/textual/events.py index d60d4f3e3..22424d691 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -35,9 +35,7 @@ class Null(Event, verbosity=3): @rich.repr.auto class Callback(Event, bubble=False, verbosity=3): def __init__( - self, - sender: MessageTarget, - callback: Callable[[], Awaitable[None]] + self, sender: MessageTarget, callback: Callable[[], Awaitable[None]] ) -> None: self.callback = callback super().__init__(sender)