From 6cb25add4a027901cb6c9a15d0aa0fccfd88280d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Jul 2021 16:53:54 +0100 Subject: [PATCH] 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()