From e94ea25fafc4c41699fa3288902057f336e803cd Mon Sep 17 00:00:00 2001 From: darrenburns Date: Tue, 16 Aug 2022 10:55:34 +0100 Subject: [PATCH 01/21] Filling the gap between horizontal and vertical scrollbars (#664) * Fill the spacing between the scrollbars * Add "scrollbar-corner-color" CSS rule and doc * Remove unused print statement * Some sandbox changes * Remove redundant words from docs * Add docstring to ScrollBarCorner class --- docs/styles/scrollbar.md | 4 ++- sandbox/darren/just_a_box.css | 57 ++++++------------------------ sandbox/darren/just_a_box.py | 24 +++++++++---- src/textual/css/_styles_builder.py | 1 + src/textual/css/styles.py | 4 +++ src/textual/scrollbar.py | 14 ++++++++ src/textual/widget.py | 22 ++++++++++-- 7 files changed, 70 insertions(+), 56 deletions(-) diff --git a/docs/styles/scrollbar.md b/docs/styles/scrollbar.md index 9a11b1cd9..514436eca 100644 --- a/docs/styles/scrollbar.md +++ b/docs/styles/scrollbar.md @@ -3,13 +3,15 @@ There are a number of rules to set the colors used in Textual scrollbars. You won't typically need to do this, as the default themes have carefully chosen colors, but you can if you want to. | Rule | Color | -| ----------------------------- | ------------------------------------------------------- | +|-------------------------------|---------------------------------------------------------| | `scrollbar-color` | Scrollbar "thumb" (movable part) | | `scrollbar-color-hover` | Scrollbar thumb when the mouse is hovering over it | | `scrollbar-color-active` | Scrollbar thumb when it is active (being dragged) | | `scrollbar-background` | Scrollbar background | | `scrollbar-background-hover` | Scrollbar background when the mouse is hovering over it | | `scrollbar-background-active` | Scrollbar background when the thumb is being dragged | +| `scrollbar-corner-color` | The gap between the horizontal and vertical scrollbars | + ## Example diff --git a/sandbox/darren/just_a_box.css b/sandbox/darren/just_a_box.css index 062dff0ec..881e436bf 100644 --- a/sandbox/darren/just_a_box.css +++ b/sandbox/darren/just_a_box.css @@ -1,61 +1,24 @@ Screen { - height: 100vh; - width: 100%; - background: red; -} - -#horizontal { - width: 100%; -} - -.box { - height: 5; - width: 5; - margin: 1 10; + background: lightcoral; } #left_pane { - width: 1fr; - background: $background; + background: red; + width: 20; + overflow: scroll scroll; } #middle_pane { - margin-top: 4; - width: 1fr; - background: #173f5f; -} - -#middle_pane:focus { - tint: cyan 40%; + background: green; + width: 140; } #right_pane { - width: 1fr; - background: #f6d55c; -} - -.box:focus { - tint: cyan 40%; -} - -#box1 { - background: green; -} - -#box2 { - offset-y: 3; - background: hotpink; -} - -#box3 { - background: red; -} - - -#box4 { background: blue; + width: 30; } -#box5 { - background: darkviolet; +.box { + height: 12; + width: 30; } diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index 8e6fc3ae7..781c66f67 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -3,6 +3,7 @@ from __future__ import annotations from rich.console import RenderableType from rich.panel import Panel +from textual import events from textual.app import App, ComposeResult from textual.layout import Horizontal, Vertical from textual.widget import Widget @@ -21,26 +22,37 @@ class Box(Widget, can_focus=True): class JustABox(App): - dark = True - def compose(self) -> ComposeResult: yield Horizontal( Vertical( Box(id="box1", classes="box"), Box(id="box2", classes="box"), - Box(id="box3", classes="box"), + # Box(id="box3", classes="box"), + # Box(id="box4", classes="box"), + # Box(id="box5", classes="box"), + # Box(id="box6", classes="box"), + # Box(id="box7", classes="box"), + # Box(id="box8", classes="box"), + # Box(id="box9", classes="box"), + # Box(id="box10", classes="box"), id="left_pane", ), Box(id="middle_pane"), Vertical( - Box(id="box", classes="box"), - Box(id="box4", classes="box"), - Box(id="box5", classes="box"), + Box(id="boxa", classes="box"), + Box(id="boxb", classes="box"), + Box(id="boxc", classes="box"), id="right_pane", ), id="horizontal", ) + def key_p(self): + print(self.query("#horizontal").first().styles.layout) + + async def on_key(self, event: events.Key) -> None: + await self.dispatch_key(event) + if __name__ == "__main__": app = JustABox(css_path="just_a_box.css", watch_css=True) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index f52021c47..4b1184127 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -600,6 +600,7 @@ class StylesBuilder: process_scrollbar_color = process_color process_scrollbar_color_hover = process_color process_scrollbar_color_active = process_color + process_scrollbar_corner_color = process_color process_scrollbar_background = process_color process_scrollbar_background_hover = process_color process_scrollbar_background_active = process_color diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 9db2f5d78..dd0b53e6f 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -125,6 +125,8 @@ class RulesMap(TypedDict, total=False): scrollbar_color_hover: Color scrollbar_color_active: Color + scrollbar_corner_color: Color + scrollbar_background: Color scrollbar_background_hover: Color scrollbar_background_active: Color @@ -228,6 +230,8 @@ class StylesBase(ABC): scrollbar_color_hover = ColorProperty("ansi_yellow") scrollbar_color_active = ColorProperty("ansi_bright_yellow") + scrollbar_corner_color = ColorProperty("#666666") + scrollbar_background = ColorProperty("#555555") scrollbar_background_hover = ColorProperty("#444444") scrollbar_background_active = ColorProperty("black") diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index ea5d510dd..7ae1dcb45 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -9,6 +9,7 @@ from rich.segment import Segment, Segments from rich.style import Style, StyleType from textual.reactive import Reactive +from textual.renderables.blank import Blank from . import events from ._types import MessageTarget from .geometry import Offset @@ -287,6 +288,19 @@ class ScrollBar(Widget): await self.emit(ScrollTo(self, x=x, y=y)) +class ScrollBarCorner(Widget): + """Widget which fills the gap between horizontal and vertical scrollbars, + should they both be present.""" + + def __init__(self, name: str | None = None): + super().__init__(name=name) + + def render(self) -> RenderableType: + styles = self.parent.styles + color = styles.scrollbar_corner_color + return Blank(color) + + if __name__ == "__main__": from rich.console import Console diff --git a/src/textual/widget.py b/src/textual/widget.py index f0ea13ad3..55630d8cc 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -17,7 +17,6 @@ import rich.repr from rich.align import Align from rich.console import Console, RenderableType from rich.measure import Measurement - from rich.segment import Segment from rich.style import Style from rich.styled import Styled @@ -46,6 +45,7 @@ if TYPE_CHECKING: ScrollRight, ScrollTo, ScrollUp, + ScrollBarCorner, ) @@ -73,6 +73,7 @@ class Widget(DOMNode): scrollbar-background-hover: $panel-darken-2; scrollbar-color: $primary-lighten-1; scrollbar-color-active: $warning-darken-1; + scrollbar-corner-color: $panel-darken-3; scrollbar-size-vertical: 2; scrollbar-size-horizontal: 1; } @@ -102,6 +103,7 @@ class Widget(DOMNode): self._vertical_scrollbar: ScrollBar | None = None self._horizontal_scrollbar: ScrollBar | None = None + self._scrollbar_corner: ScrollBarCorner | None = None self._render_cache = RenderCache(Size(0, 0), []) # Regions which need to be updated (in Widget) @@ -353,6 +355,19 @@ class Widget(DOMNode): + self.scrollbar_size_horizontal, ) + @property + def scrollbar_corner(self) -> ScrollBarCorner: + """Return the ScrollBarCorner - the cells that appear between the + horizontal and vertical scrollbars (only when both are visible). + """ + from .scrollbar import ScrollBarCorner + + if self._scrollbar_corner is not None: + return self._scrollbar_corner + self._scrollbar_corner = ScrollBarCorner() + self.app.start_widget(self, self._scrollbar_corner) + return self._scrollbar_corner + @property def vertical_scrollbar(self) -> ScrollBar: """Get a vertical scrollbar (create if necessary) @@ -918,15 +933,18 @@ class Widget(DOMNode): _, vertical_scrollbar_region, horizontal_scrollbar_region, - _, + scrollbar_corner_gap, ) = region.split( -scrollbar_size_vertical, -scrollbar_size_horizontal, ) + if scrollbar_corner_gap: + yield self.scrollbar_corner, scrollbar_corner_gap if vertical_scrollbar_region: yield self.vertical_scrollbar, vertical_scrollbar_region if horizontal_scrollbar_region: yield self.horizontal_scrollbar, horizontal_scrollbar_region + elif show_vertical_scrollbar: _, scrollbar_region = region.split_vertical(-scrollbar_size_vertical) if scrollbar_region: From ff55dafb8638f6674f3662aa526a5fc35a007b24 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 11:00:47 +0100 Subject: [PATCH 02/21] prototype screens api --- sandbox/uber.py | 2 +- sandbox/will/screens.py | 65 ++++++++++++++++ src/textual/app.py | 124 ++++++++++++++++++++++++------- src/textual/events.py | 8 ++ src/textual/screen.py | 45 ++++++----- src/textual/widget.py | 2 +- src/textual/widgets/__init__.py | 1 + src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_pretty.py | 23 ++++++ 9 files changed, 227 insertions(+), 44 deletions(-) create mode 100644 sandbox/will/screens.py create mode 100644 src/textual/widgets/_pretty.py diff --git a/sandbox/uber.py b/sandbox/uber.py index 96f70fd0d..72917dff2 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -43,7 +43,7 @@ class BasicApp(App): self.panic(self.app.tree) def action_dump(self): - self.panic(str(self.app.registry)) + self.panic(str(self.app._registry)) def action_log_tree(self): self.log(self.screen.tree) diff --git a/sandbox/will/screens.py b/sandbox/will/screens.py new file mode 100644 index 000000000..3052a3437 --- /dev/null +++ b/sandbox/will/screens.py @@ -0,0 +1,65 @@ +from textual.app import App, Screen, ComposeResult +from textual.widgets import Static, Footer, Pretty + + +class ModalScreen(Screen): + def compose(self) -> ComposeResult: + yield Pretty(self.app._screen_stack) + yield Footer() + + def on_screen_resume(self): + self.query("*").refresh() + + +class NewScreen(Screen): + def compose(self): + yield Pretty(self.app._screen_stack) + yield Footer() + + def on_screen_resume(self): + self.query("*").refresh() + + +class ScreenApp(App): + CSS = """ + ScreenApp Screen { + background: #111144; + color: white; + } + ScreenApp ModalScreen { + background: #114411; + color: white; + } + ScreenApp Static { + height: 100%; + content-align: center middle; + } + """ + + SCREENS = { + "1": NewScreen("screen 1"), + "2": NewScreen("screen 2"), + "3": NewScreen("screen 3"), + } + + def compose(self) -> ComposeResult: + yield Static("On Screen 1") + yield Footer() + + def on_mount(self) -> None: + self.bind("1", "switch_screen('1')", description="Screen 1") + self.bind("2", "switch_screen('2')", description="Screen 2") + self.bind("3", "switch_screen('3')", description="Screen 3") + self.bind("s", "modal_screen", description="add screen") + self.bind("escape", "back", description="Go back") + + def action_back(self) -> None: + self.pop_screen() + self.log(self.app._registry) + + def action_modal_screen(self) -> None: + self.push_screen(ModalScreen()) + + +app = ScreenApp() +app.run() diff --git a/src/textual/app.py b/src/textual/app.py index 8a843c3fc..efeb04549 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -21,6 +21,7 @@ from typing import ( TypeVar, TYPE_CHECKING, ) +from weakref import WeakValueDictionary from ._ansi_sequences import SYNC_START, SYNC_END @@ -112,6 +113,10 @@ class ActionError(Exception): pass +class ScreenStackError(Exception): + pass + + ReturnType = TypeVar("ReturnType") @@ -126,6 +131,8 @@ class App(Generic[ReturnType], DOMNode): } """ + SCREENS: dict[str, Screen] = {} + CSS_PATH: str | None = None def __init__( @@ -208,7 +215,8 @@ class App(Generic[ReturnType], DOMNode): self._require_stylesheet_update = False self.css_path = css_path or self.CSS_PATH - self.registry: set[MessagePump] = set() + self._registry: set[DOMNode] = set() + self.devtools = DevtoolsClient() self._return_value: ReturnType | None = None @@ -243,6 +251,11 @@ class App(Generic[ReturnType], DOMNode): """Check if the app is running in 'headless' mode.""" return "headless" in self.features + @property + def screen_stack(self) -> list[Screen]: + """Get a *copy* of the screen stack.""" + return self._screen_stack.copy() + def exit(self, result: ReturnType | None = None) -> None: """Exit the app, and return the supplied result. @@ -503,6 +516,7 @@ class App(Generic[ReturnType], DOMNode): self, keys: str, action: str, + *, description: str = "", show: bool = True, key_display: str | None = None, @@ -601,19 +615,76 @@ class App(Generic[ReturnType], DOMNode): for widget in widgets: self._register(self.screen, widget) - def push_screen(self, screen: Screen | None = None) -> Screen: + def _get_screen(self, screen: Screen | str) -> Screen: + """Get a screen and ensure it is registered. + + Args: + screen (Screen | str): A screen instance or a named screen. + + Raises: + KeyError: If the named screen doesn't exist. + + Returns: + Screen: A screen instance. + """ + if isinstance(screen, str): + try: + next_screen = self.SCREENS[screen] + except KeyError: + raise KeyError( + "No screen called {screen!r} found in {self.__class__}.SCREENS" + ) from None + else: + next_screen = screen + if not next_screen.is_running: + self._register(self, next_screen) + return next_screen + + def push_screen(self, screen: Screen | str) -> None: """Push a new screen on the screen stack. Args: - screen (Screen | None, optional): A new Screen instance or None to create - one internally. Defaults to None. + screen (Screen | str): A Screen instance or an id. Returns: Screen: Newly active screen. """ - new_screen = Screen() if screen is None else screen - self._screen_stack.append(new_screen) - return new_screen + next_screen = self._get_screen(screen) + self._screen_stack.append(next_screen) + self.screen.post_message_no_wait(events.ScreenResume(self)) + + def switch_screen(self, screen: Screen | str) -> Screen: + """Switch to a another screen. + + Args: + screen (Screen | str): A screen instance or a named of a screen. + + Returns: + Screen: The previous screen object. + """ + next_screen = self._get_screen(screen) + current_screen = self._screen_stack.pop() + current_screen.post_message_no_wait(events.ScreenSuspend(self)) + self._screen_stack.append(next_screen) + self.screen.post_message_no_wait(events.ScreenResume(self)) + return current_screen + + def pop_screen(self) -> Screen: + """Pop the current screen from the stack, and switch to the previous screen. + + Returns: + Screen: The screen that was replaced. + """ + screen_stack = self._screen_stack + if len(screen_stack) <= 1: + raise ScreenStackError( + "Can't pop screen; there must be at least one screen on the stack" + ) + screen = screen_stack.pop() + screen.post_message_no_wait(events.ScreenSuspend(self)) + self.screen._screen_resized(self.size) + self.screen.post_message_no_wait(events.ScreenResume(self)) + return screen def set_focus(self, widget: Widget | None) -> None: """Focus (or unfocus) a widget. A focused widget will receive key events first. @@ -835,10 +906,10 @@ class App(Generic[ReturnType], DOMNode): self._require_stylesheet_update = False self.stylesheet.update(self, animate=True) - def _register_child(self, parent: DOMNode, child: DOMNode) -> bool: - if child not in self.registry: + def _register_child(self, parent: DOMNode, child: Widget) -> bool: + if child not in self._registry: parent.children._append(child) - self.registry.add(child) + self._registry.add(child) child.set_parent(parent) child.on_register(self) child.start_messages() @@ -862,7 +933,7 @@ class App(Generic[ReturnType], DOMNode): apply_stylesheet = self.stylesheet.apply for widget_id, widget in name_widgets: - if widget not in self.registry: + if widget not in self._registry: if widget_id is not None: widget.id = widget_id self._register_child(parent, widget) @@ -881,7 +952,7 @@ class App(Generic[ReturnType], DOMNode): """ if isinstance(widget._parent, Widget): widget._parent.children._remove(widget) - self.registry.discard(widget) + self._registry.discard(widget) async def _disconnect_devtools(self): await self.devtools.disconnect() @@ -898,11 +969,11 @@ class App(Generic[ReturnType], DOMNode): widget.post_message_no_wait(events.Mount(sender=parent)) def is_mounted(self, widget: Widget) -> bool: - return widget in self.registry + return widget in self._registry async def close_all(self) -> None: - while self.registry: - child = self.registry.pop() + while self._registry: + child = self._registry.pop() await child.close_messages() async def shutdown(self): @@ -916,10 +987,6 @@ class App(Generic[ReturnType], DOMNode): self.screen.refresh(repaint=repaint, layout=layout) self.check_idle() - def _paint(self): - """Perform a "paint" (draw the screen).""" - self._display(self.screen._compositor.render()) - def refresh_css(self, animate: bool = True) -> None: """Refresh CSS. @@ -932,13 +999,14 @@ class App(Generic[ReturnType], DOMNode): stylesheet.update(self.app, animate=animate) self.screen._refresh_layout(self.size, full=True) - def _display(self, renderable: RenderableType | None) -> None: + def _display(self, screen: Screen, renderable: RenderableType | None) -> None: """Display a renderable within a sync. Args: + screen (Screen): Screen instance renderable (RenderableType): A Rich renderable. """ - if renderable is None: + if screen is not self.screen or renderable is None: return if self._running and not self._closed and not self.is_headless: console = self.console @@ -1004,7 +1072,7 @@ class App(Generic[ReturnType], DOMNode): # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Mount): - screen = Screen() + screen = Screen(id="_default") self._register(self, screen) self.push_screen(screen) await super().on_event(event) @@ -1104,11 +1172,9 @@ class App(Generic[ReturnType], DOMNode): async def on_update(self, message: messages.Update) -> None: message.stop() - self._paint() async def on_layout(self, message: messages.Layout) -> None: message.stop() - self._paint() async def on_key(self, event: events.Key) -> None: if event.key == "tab": @@ -1125,7 +1191,6 @@ class App(Generic[ReturnType], DOMNode): async def on_resize(self, event: events.Resize) -> None: event.stop() self.screen._screen_resized(event.size) - await self.screen.post_message(event) async def action_press(self, key: str) -> None: @@ -1149,6 +1214,15 @@ class App(Generic[ReturnType], DOMNode): if isinstance(node, Widget): self.set_focus(node) + async def action_switch_screen(self, screen: str) -> None: + self.switch_screen(screen) + + async def action_push_screen(self, screen: str) -> None: + self.push_screen(screen) + + async def action_pop_screen(self) -> None: + self.pop_screen() + async def action_add_class_(self, selector: str, class_name: str) -> None: self.screen.query(selector).add_class(class_name) diff --git a/src/textual/events.py b/src/textual/events.py index 687a2490b..b3ffec6a0 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -436,3 +436,11 @@ class Paste(Event, bubble=False): def __rich_repr__(self) -> rich.repr.Result: yield "text", self.text + + +class ScreenResume(Event, bubble=False): + pass + + +class ScreenSuspend(Event, bubble=False): + pass diff --git a/src/textual/screen.py b/src/textual/screen.py index 5e20862b2..61c19aa7c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -52,6 +52,10 @@ class Screen(Widget): def is_transparent(self) -> bool: return False + @property + def is_current(self) -> bool: + return self.app.screen is self + @property def update_timer(self) -> Timer: """Timer used to perform updates.""" @@ -113,20 +117,22 @@ class Screen(Widget): """ return self._compositor.find_widget(widget) - async def on_idle(self, event: events.Idle) -> None: + async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) event.prevent_default() - if self._layout_required: - self._refresh_layout() - self._layout_required = False - self._dirty_widgets.clear() - if self._repaint_required: - self._dirty_widgets.clear() - self._dirty_widgets.add(self) - self._repaint_required = False - if self._dirty_widgets: - self.update_timer.resume() + if self.is_current: + if self._layout_required: + self._refresh_layout() + self._layout_required = False + self._dirty_widgets.clear() + if self._repaint_required: + self._dirty_widgets.clear() + self._dirty_widgets.add(self) + self._repaint_required = False + + if self._dirty_widgets: + self.update_timer.resume() # The Screen is idle - a good opportunity to invoke the scheduled callbacks await self._invoke_and_clear_callbacks() @@ -136,14 +142,14 @@ class Screen(Widget): # Render widgets together if self._dirty_widgets: self._compositor.update_widgets(self._dirty_widgets) - self.app._display(self._compositor.render()) + self.app._display(self, self._compositor.render()) self._dirty_widgets.clear() self.update_timer.pause() if self._callbacks: self.post_message_no_wait(events.InvokeCallbacks(self)) - async def on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None: + async def _on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None: """Handle PostScreenUpdate events, which are sent after the screen is updated""" await self._invoke_and_clear_callbacks() @@ -205,9 +211,9 @@ class Screen(Widget): return display_update = self._compositor.render(full=full) if display_update is not None: - self.app._display(display_update) + self.app._display(self, display_update) - async def on_update(self, message: messages.Update) -> None: + async def _on_update(self, message: messages.Update) -> None: message.stop() message.prevent_default() widget = message.widget @@ -215,7 +221,7 @@ class Screen(Widget): self._dirty_widgets.add(widget) self.check_idle() - async def on_layout(self, message: messages.Layout) -> None: + async def _on_layout(self, message: messages.Layout) -> None: message.stop() message.prevent_default() self._layout_required = True @@ -225,7 +231,12 @@ class Screen(Widget): """Called by App when the screen is resized.""" self._refresh_layout(size, full=True) - async def on_resize(self, event: events.Resize) -> None: + def _on_screen_resume(self) -> None: + """Called by the App""" + size = self.app.size + self._refresh_layout(size, full=True) + + async def _on_resize(self, event: events.Resize) -> None: event.stop() async def _handle_mouse_move(self, event: events.MouseMove) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 55630d8cc..b75394ca3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1127,7 +1127,7 @@ class Widget(DOMNode): self.log(self, f"IS NOT RUNNING, {message!r} not sent") return await super().post_message(message) - def on_idle(self, event: events.Idle) -> None: + async def _on_idle(self, event: events.Idle) -> None: """Called when there are no more events on the queue. Args: diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 76d786822..4231127fc 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -17,6 +17,7 @@ __all__ = [ "Footer", "Header", "Placeholder", + "Pretty", "Static", "TreeControl", ] diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index b065bc432..b903300c5 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -5,5 +5,6 @@ from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer from ._header import Header as Header from ._placeholder import Placeholder as Placeholder +from ._pretty import Pretty as Pretty from ._static import Static as Static from ._tree_control import TreeControl as TreeControl diff --git a/src/textual/widgets/_pretty.py b/src/textual/widgets/_pretty.py new file mode 100644 index 000000000..d6a406483 --- /dev/null +++ b/src/textual/widgets/_pretty.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any +from rich.pretty import Pretty as PrettyRenderable +from ._static import Static + + +class Pretty(Static): + def __init__( + self, + object: Any, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + self._object = object + super().__init__( + PrettyRenderable(self._object), + name=name, + id=id, + classes=classes, + ) From a9fb78edc1dd768f6620be850985a5847cabd67b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 11:39:40 +0100 Subject: [PATCH 03/21] screens api --- sandbox/will/screens.py | 8 ++------ src/textual/app.py | 12 +++++++++--- src/textual/dom.py | 5 +++++ src/textual/message_pump.py | 2 +- src/textual/widget.py | 2 ++ 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/sandbox/will/screens.py b/sandbox/will/screens.py index 3052a3437..6bb425303 100644 --- a/sandbox/will/screens.py +++ b/sandbox/will/screens.py @@ -4,7 +4,7 @@ from textual.widgets import Static, Footer, Pretty class ModalScreen(Screen): def compose(self) -> ComposeResult: - yield Pretty(self.app._screen_stack) + yield Pretty(self.app.screen_stack) yield Footer() def on_screen_resume(self): @@ -13,7 +13,7 @@ class ModalScreen(Screen): class NewScreen(Screen): def compose(self): - yield Pretty(self.app._screen_stack) + yield Pretty(self.app.screen_stack) yield Footer() def on_screen_resume(self): @@ -53,10 +53,6 @@ class ScreenApp(App): self.bind("s", "modal_screen", description="add screen") self.bind("escape", "back", description="Go back") - def action_back(self) -> None: - self.pop_screen() - self.log(self.app._registry) - def action_modal_screen(self) -> None: self.push_screen(ModalScreen()) diff --git a/src/textual/app.py b/src/textual/app.py index efeb04549..b1f292a19 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -21,7 +21,7 @@ from typing import ( TypeVar, TYPE_CHECKING, ) -from weakref import WeakValueDictionary +from weakref import WeakSet from ._ansi_sequences import SYNC_START, SYNC_END @@ -183,7 +183,6 @@ class App(Generic[ReturnType], DOMNode): self._driver: Driver | None = None self._exit_renderables: list[RenderableType] = [] - self._docks: list[Dock] = [] self._action_targets = {"app", "screen"} self._animator = Animator(self) self.animate = self._animator.bind(self) @@ -215,7 +214,7 @@ class App(Generic[ReturnType], DOMNode): self._require_stylesheet_update = False self.css_path = css_path or self.CSS_PATH - self._registry: set[DOMNode] = set() + self._registry: WeakSet[DOMNode] = WeakSet() self.devtools = DevtoolsClient() self._return_value: ReturnType | None = None @@ -681,6 +680,7 @@ class App(Generic[ReturnType], DOMNode): "Can't pop screen; there must be at least one screen on the stack" ) screen = screen_stack.pop() + screen.remove() screen.post_message_no_wait(events.ScreenSuspend(self)) self.screen._screen_resized(self.size) self.screen.post_message_no_wait(events.ScreenResume(self)) @@ -1223,6 +1223,12 @@ class App(Generic[ReturnType], DOMNode): async def action_pop_screen(self) -> None: self.pop_screen() + async def action_back(self) -> None: + try: + self.pop_screen() + except ScreenStackError: + pass + async def action_add_class_(self, selector: str, class_name: str) -> None: self.screen.query(selector).add_class(class_name) diff --git a/src/textual/dom.py b/src/textual/dom.py index 3220a06b8..dab6b494b 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -442,6 +442,11 @@ class DOMNode(MessagePump): """The children which don't have display: none set.""" return [child for child in self.children if child.display] + def detach(self) -> None: + if self._parent and isinstance(self._parent, DOMNode): + self._parent.children._remove(self) + self.set_parent(None) + def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 71c964de8..620dc266e 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -122,7 +122,7 @@ class MessagePump(metaclass=MessagePumpMeta): def log(self, *args, **kwargs) -> None: return self.app.log(*args, **kwargs, _textual_calling_frame=inspect.stack()[1]) - def set_parent(self, parent: MessagePump) -> None: + def set_parent(self, parent: MessagePump | None) -> None: self._parent = parent def check_message_enabled(self, message: Message) -> bool: diff --git a/src/textual/widget.py b/src/textual/widget.py index b75394ca3..51dde4dbb 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1104,6 +1104,8 @@ class Widget(DOMNode): def remove(self) -> None: """Remove the Widget from the DOM (effectively deleting it)""" + for child in self.children: + child.remove() self.post_message_no_wait(events.Remove(self)) def render(self) -> RenderableType: From 62f7ed83582d791f06edf3b8566b4bb0643ef542 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 17:06:33 +0100 Subject: [PATCH 04/21] alignment fix --- sandbox/will/screens.py | 11 +++-- src/textual/_border.py | 2 + src/textual/_segment_tools.py | 73 ++++++++++++++++++++++++++++++++ src/textual/app.py | 27 +++++++++--- src/textual/css/stylesheet.py | 2 +- src/textual/dom.py | 11 +++-- src/textual/message_pump.py | 11 ++++- src/textual/renderables/align.py | 48 +++++++++++++++++++++ src/textual/screen.py | 1 - src/textual/widget.py | 26 +++++++++--- src/textual/widgets/_pretty.py | 21 +++++++-- 11 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 src/textual/renderables/align.py diff --git a/sandbox/will/screens.py b/sandbox/will/screens.py index 6bb425303..750035abb 100644 --- a/sandbox/will/screens.py +++ b/sandbox/will/screens.py @@ -8,7 +8,7 @@ class ModalScreen(Screen): yield Footer() def on_screen_resume(self): - self.query("*").refresh() + self.query_one(Pretty).update(self.app.screen_stack) class NewScreen(Screen): @@ -25,14 +25,19 @@ class ScreenApp(App): ScreenApp Screen { background: #111144; color: white; + + } ScreenApp ModalScreen { background: #114411; color: white; + + } - ScreenApp Static { - height: 100%; + ScreenApp Pretty { + height: auto; content-align: center middle; + background: white 20%; } """ diff --git a/src/textual/_border.py b/src/textual/_border.py index 99dcc0665..516951e37 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -263,6 +263,8 @@ class Border: else: render_options = options.update_width(width) + print("LINES", self.renderable) + print(render_options) lines = console.render_lines(self.renderable, render_options) if self.outline: diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index df9455f8e..d8788df17 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -10,6 +10,9 @@ from rich.segment import Segment from rich.style import Style from ._cells import cell_len +from ._types import Lines +from .css.types import AlignHorizontal, AlignVertical +from .geometry import Size def line_crop( @@ -124,3 +127,73 @@ def line_pad( Segment(" " * pad_right, style), ] return list(segments) + + +def align_lines( + lines: Lines, + style: Style, + size: Size, + horizontal: AlignHorizontal, + vertical: AlignVertical, +) -> Iterable[list[Segment]]: + """Align lines. + + Args: + lines (Lines): A list of lines. + style (Style): Background style. + size (Size): Size of container. + horizontal (AlignHorizontal): Horizontal alignment. + vertical (AlignVertical): Vertical alignment + + Returns: + Iterable[list[Segment]]: Aligned lines. + + """ + + width, height = size + shape_width, shape_height = Segment.get_shape(lines) + + print("len lines", len(lines)) + print(width, height) + print(shape_width, shape_height) + + def blank_lines(count: int) -> Lines: + return [[Segment(" " * width, style)]] * count + + top_blank_lines = bottom_blank_lines = 0 + vertical_excess_space = max(0, height - shape_height) + print("VERTICAL EXCESS", vertical_excess_space) + print("height", height, "shape height", shape_height) + if vertical == "top": + bottom_blank_lines = vertical_excess_space + elif vertical == "middle": + top_blank_lines = vertical_excess_space // 2 + bottom_blank_lines = height - top_blank_lines + elif vertical == "bottom": + top_blank_lines = vertical_excess_space + + print(top_blank_lines) + yield from blank_lines(top_blank_lines) + + horizontal_excess_space = max(0, width - shape_width) + + adjust_line_length = Segment.adjust_line_length + if horizontal == "left": + for line in lines: + yield adjust_line_length(line, width, style, pad=True) + + elif horizontal == "center": + left_space = horizontal_excess_space // 2 + for line in lines: + yield [ + Segment(" " * left_space, style), + *adjust_line_length(line, width - left_space, style, pad=True), + ] + + elif horizontal == "right": + get_line_length = Segment.get_line_length + for line in lines: + left_space = width - get_line_length(line) + yield [*line, Segment(" " * left_space, style)] + + yield from blank_lines(bottom_blank_lines) diff --git a/src/textual/app.py b/src/textual/app.py index b1f292a19..993b10f4f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -668,7 +668,7 @@ class App(Generic[ReturnType], DOMNode): self.screen.post_message_no_wait(events.ScreenResume(self)) return current_screen - def pop_screen(self) -> Screen: + def pop_screen(self, remove: bool | None = None) -> Screen: """Pop the current screen from the stack, and switch to the previous screen. Returns: @@ -680,10 +680,25 @@ class App(Generic[ReturnType], DOMNode): "Can't pop screen; there must be at least one screen on the stack" ) screen = screen_stack.pop() - screen.remove() screen.post_message_no_wait(events.ScreenSuspend(self)) self.screen._screen_resized(self.size) self.screen.post_message_no_wait(events.ScreenResume(self)) + + if remove is None: + if screen not in self.SCREENS.values(): + screen.remove() + else: + screen.detach() + else: + if remove: + if screen in self.SCREENS.values(): + raise ScreenStackError("Can't remove screen set in App.SCREENS") + screen.remove() + else: + screen.detach() + + print(self._registry) + return screen def set_focus(self, widget: Widget | None) -> None: @@ -692,7 +707,6 @@ class App(Generic[ReturnType], DOMNode): Args: widget (Widget): [description] """ - self.log("set_focus", widget=widget) if widget == self.focused: # Widget is already focused return @@ -910,7 +924,7 @@ class App(Generic[ReturnType], DOMNode): if child not in self._registry: parent.children._append(child) self._registry.add(child) - child.set_parent(parent) + child._attach(parent) child.on_register(self) child.start_messages() return True @@ -948,10 +962,11 @@ class App(Generic[ReturnType], DOMNode): """Unregister a widget. Args: - widget (Widget): _description_ + widget (Widget): A Widget to unregister """ if isinstance(widget._parent, Widget): widget._parent.children._remove(widget) + widget._attach(None) self._registry.discard(widget) async def _disconnect_devtools(self): @@ -964,7 +979,7 @@ class App(Generic[ReturnType], DOMNode): parent (Widget): The parent of the Widget. widget (Widget): The Widget to start. """ - widget.set_parent(parent) + widget._attach(parent) widget.start_messages() widget.post_message_no_wait(events.Mount(sender=parent)) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 06957892e..8a0053214 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -355,7 +355,7 @@ class Stylesheet: node._component_styles.clear() for component in node.COMPONENT_CLASSES: virtual_node = DOMNode(classes=component) - virtual_node.set_parent(node) + virtual_node._attach(node) self.apply(virtual_node, animate=False) node._component_styles[component] = virtual_node.styles diff --git a/src/textual/dom.py b/src/textual/dom.py index dab6b494b..0edb69f23 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -445,7 +445,10 @@ class DOMNode(MessagePump): def detach(self) -> None: if self._parent and isinstance(self._parent, DOMNode): self._parent.children._remove(self) - self.set_parent(None) + print(self.parent.children) + self._detach() + print("DETATCH", self) + print(self.app._registry) def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. @@ -472,7 +475,7 @@ class DOMNode(MessagePump): node (DOMNode): A DOM node. """ self.children._append(node) - node.set_parent(self) + node._attach(self) def add_children(self, *nodes: Widget, **named_nodes: Widget) -> None: """Add multiple children to this node. @@ -483,10 +486,10 @@ class DOMNode(MessagePump): """ _append = self.children._append for node in nodes: - node.set_parent(self) + node._attach(self) _append(node) for node_id, node in named_nodes.items(): - node.set_parent(self) + node._attach(self) _append(node) node.id = node_id diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 620dc266e..b263fea83 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -122,9 +122,18 @@ class MessagePump(metaclass=MessagePumpMeta): def log(self, *args, **kwargs) -> None: return self.app.log(*args, **kwargs, _textual_calling_frame=inspect.stack()[1]) - def set_parent(self, parent: MessagePump | None) -> None: + def _attach(self, parent: MessagePump) -> None: + """Set the parent, and therefore attach this node to the tree. + + Args: + parent (MessagePump): Parent node. + """ self._parent = parent + def _detach(self) -> None: + """Unset the parent, removing it from the tree.""" + self._parent = None + def check_message_enabled(self, message: Message) -> bool: return type(message) not in self._disabled_messages diff --git a/src/textual/renderables/align.py b/src/textual/renderables/align.py new file mode 100644 index 000000000..ab5ec8458 --- /dev/null +++ b/src/textual/renderables/align.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.measure import Measurement +from rich.segment import Segment +from rich.style import Style + +from ..geometry import Size +from ..css.types import AlignHorizontal, AlignVertical +from .._segment_tools import align_lines + + +class Align: + def __init__( + self, + renderable: RenderableType, + size: Size, + style: Style, + horizontal: AlignHorizontal, + vertical: AlignVertical, + ) -> None: + self.renderable = renderable + self.size = size + self.style = style + self.horizontal = horizontal + self.vertical = vertical + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + lines = console.render_lines(self.renderable, options, pad=False) + + new_line = Segment.line() + for line in align_lines( + lines, + self.style, + self.size, + self.horizontal, + self.vertical, + ): + yield from line + yield new_line + + def __rich_measure__( + self, console: "Console", options: "ConsoleOptions" + ) -> Measurement: + width, _ = self.size + return Measurement(width, width) diff --git a/src/textual/screen.py b/src/textual/screen.py index 61c19aa7c..b427b9e89 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -33,7 +33,6 @@ class Screen(Widget): CSS = """ Screen { - layout: vertical; overflow-y: auto; } diff --git a/src/textual/widget.py b/src/textual/widget.py index 51dde4dbb..74a77a4a7 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -14,7 +14,7 @@ from typing import ( ) import rich.repr -from rich.align import Align + from rich.console import Console, RenderableType from rich.measure import Measurement from rich.segment import Segment @@ -27,6 +27,7 @@ from ._animator import BoundAnimator from ._arrange import arrange, DockArrangeResult from ._context import active_app from ._layout import Layout +from ._segment_tools import align_lines from ._styles_cache import StylesCache from ._types import Lines from .box_model import BoxModel, get_box_model @@ -35,6 +36,8 @@ from .geometry import Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message from .reactive import Reactive, watch +from .renderables.align import Align + if TYPE_CHECKING: from .app import App, ComposeResult @@ -287,6 +290,7 @@ class Widget(DOMNode): Returns: int: The height of the content. """ + if self.is_container: assert self.layout is not None height = ( @@ -306,7 +310,7 @@ class Widget(DOMNode): renderable = self.render() options = self.console.options.update_width(width).update(highlight=False) - segments = self.console.render(renderable, options) + segments = list(self.console.render(renderable, options)) # Cheaper than counting the lines returned from render_lines! height = sum(text.count("\n") for text, _, _ in segments) self._content_height_cache = (cache_key, height) @@ -989,7 +993,15 @@ class Widget(DOMNode): ) if content_align != ("left", "top"): horizontal, vertical = content_align - renderable = Align(renderable, horizontal, vertical=vertical) + # TODO: This changes the shape of the renderable and breaks alignment + # We need custom functionality that doesn't measure the renderable again + renderable = Align( + renderable, + self.size, + rich_style, + horizontal, + vertical, + ) return renderable @@ -1030,8 +1042,10 @@ class Widget(DOMNode): width, height = self.size renderable = self.render() renderable = self.post_render(renderable) - options = self.console.options.update_dimensions(width, height).update( - highlight=False + options = ( + self.console.options.update_width(width) + .update(highlight=False) + .reset_height() ) lines = self.console.render_lines(renderable, options) self._render_cache = RenderCache(self.size, lines) @@ -1180,9 +1194,9 @@ class Widget(DOMNode): async def on_remove(self, event: events.Remove) -> None: await self.close_messages() - self.app._unregister(self) assert self.parent self.parent.refresh(layout=True) + self.app._unregister(self) def _on_mount(self, event: events.Mount) -> None: widgets = list(self.compose()) diff --git a/src/textual/widgets/_pretty.py b/src/textual/widgets/_pretty.py index d6a406483..44b64ff0b 100644 --- a/src/textual/widgets/_pretty.py +++ b/src/textual/widgets/_pretty.py @@ -2,10 +2,17 @@ from __future__ import annotations from typing import Any from rich.pretty import Pretty as PrettyRenderable -from ._static import Static + +from ..widget import Widget -class Pretty(Static): +class Pretty(Widget): + CSS = """ + Static { + height: auto; + } + """ + def __init__( self, object: Any, @@ -14,10 +21,16 @@ class Pretty(Static): id: str | None = None, classes: str | None = None, ) -> None: - self._object = object super().__init__( - PrettyRenderable(self._object), name=name, id=id, classes=classes, ) + self._renderable = PrettyRenderable(object) + + def render(self) -> PrettyRenderable: + return self._renderable + + def update(self, object: Any) -> None: + self._renderable = PrettyRenderable(object) + self.refresh() From 346feea2d6d173b27a943d349a491ee6c9ace759 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 17:45:16 +0100 Subject: [PATCH 05/21] default classes --- src/textual/_segment_tools.py | 8 +------- src/textual/dom.py | 3 +++ src/textual/widget.py | 30 ++++++++++++++---------------- src/textual/widgets/_header.py | 3 ++- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index d8788df17..9863a9b59 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -153,17 +153,12 @@ def align_lines( width, height = size shape_width, shape_height = Segment.get_shape(lines) - print("len lines", len(lines)) - print(width, height) - print(shape_width, shape_height) - def blank_lines(count: int) -> Lines: return [[Segment(" " * width, style)]] * count top_blank_lines = bottom_blank_lines = 0 vertical_excess_space = max(0, height - shape_height) - print("VERTICAL EXCESS", vertical_excess_space) - print("height", height, "shape height", shape_height) + if vertical == "top": bottom_blank_lines = vertical_excess_space elif vertical == "middle": @@ -172,7 +167,6 @@ def align_lines( elif vertical == "bottom": top_blank_lines = vertical_excess_space - print(top_blank_lines) yield from blank_lines(top_blank_lines) horizontal_excess_space = max(0, width - shape_width) diff --git a/src/textual/dom.py b/src/textual/dom.py index 0edb69f23..118f1bdef 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -43,6 +43,9 @@ class DOMNode(MessagePump): # Custom CSS CSS: ClassVar[str] = "" + # Default classes argument if not supplied + DEFAULT_CLASSES: str = "" + # Virtual DOM nodes COMPONENT_CLASSES: ClassVar[set[str]] = set() diff --git a/src/textual/widget.py b/src/textual/widget.py index 74a77a4a7..9a7b63e71 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -81,7 +81,6 @@ class Widget(DOMNode): scrollbar-size-horizontal: 1; } """ - COMPONENT_CLASSES: ClassVar[set[str]] = set() can_focus: bool = False @@ -124,7 +123,11 @@ class Widget(DOMNode): self._styles_cache = StylesCache() - super().__init__(name=name, id=id, classes=classes) + super().__init__( + name=name, + id=id, + classes=self.DEFAULT_CLASSES if classes is None else classes, + ) self.add_children(*children) virtual_size = Reactive(Size(0, 0), layout=True) @@ -987,21 +990,16 @@ class Widget(DOMNode): renderable = Styled(renderable, rich_style) styles = self.styles - content_align = ( - styles.content_align_horizontal, - styles.content_align_vertical, + horizontal, vertical = styles.content_align + # TODO: This changes the shape of the renderable and breaks alignment + # We need custom functionality that doesn't measure the renderable again + renderable = Align( + renderable, + self.size, + rich_style, + horizontal, + vertical, ) - if content_align != ("left", "top"): - horizontal, vertical = content_align - # TODO: This changes the shape of the renderable and breaks alignment - # We need custom functionality that doesn't measure the renderable again - renderable = Align( - renderable, - self.size, - rich_style, - horizontal, - vertical, - ) return renderable diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 71f5477d4..39b3c8893 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -83,6 +83,8 @@ class Header(Widget): } """ + DEFAULT_CLASSES = "tall" + async def on_click(self, event): self.toggle_class("tall") @@ -95,7 +97,6 @@ class Header(Widget): watch(self.app, "title", set_title) watch(self.app, "sub_title", set_sub_title) - self.add_class("tall") def compose(self): yield HeaderIcon() From 431ac5dd311ffafd7feb5f21c9b4da0d7ac73f90 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 19:25:25 +0100 Subject: [PATCH 06/21] Render fix --- src/textual/app.py | 2 -- src/textual/widget.py | 8 +++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 993b10f4f..b8a084f31 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -697,8 +697,6 @@ class App(Generic[ReturnType], DOMNode): else: screen.detach() - print(self._registry) - return screen def set_focus(self, widget: Widget | None) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 9a7b63e71..e003a6b74 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -313,7 +313,7 @@ class Widget(DOMNode): renderable = self.render() options = self.console.options.update_width(width).update(highlight=False) - segments = list(self.console.render(renderable, options)) + segments = self.console.render(renderable, options) # Cheaper than counting the lines returned from render_lines! height = sum(text.count("\n") for text, _, _ in segments) self._content_height_cache = (cache_key, height) @@ -1040,10 +1040,8 @@ class Widget(DOMNode): width, height = self.size renderable = self.render() renderable = self.post_render(renderable) - options = ( - self.console.options.update_width(width) - .update(highlight=False) - .reset_height() + options = self.console.options.update_dimensions(width, height).update( + highlight=False ) lines = self.console.render_lines(renderable, options) self._render_cache = RenderCache(self.size, lines) From 2284a870405694875b8045b5e5f1b63593f30047 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 19:30:02 +0100 Subject: [PATCH 07/21] fix demo --- sandbox/will/basic.css | 2 +- src/textual/renderables/align.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index ab50375b6..f37d6f7d2 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -98,7 +98,7 @@ Tweet { height:12; width: 100%; - + margin:0 2; background: $panel; color: $text-panel; layout: vertical; diff --git a/src/textual/renderables/align.py b/src/textual/renderables/align.py index ab5ec8458..cb582c137 100644 --- a/src/textual/renderables/align.py +++ b/src/textual/renderables/align.py @@ -29,7 +29,6 @@ class Align: self, console: Console, options: ConsoleOptions ) -> RenderResult: lines = console.render_lines(self.renderable, options, pad=False) - new_line = Segment.line() for line in align_lines( lines, From d7922c41e2c48f769a46f210dad2d518ae317f7c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 20:07:51 +0100 Subject: [PATCH 08/21] fix align --- src/textual/dom.py | 3 --- src/textual/renderables/align.py | 4 ++-- src/textual/widget.py | 38 +++++++++++++++++++++----------- src/textual/widgets/_static.py | 2 +- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 118f1bdef..a5d2ac4f5 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -448,10 +448,7 @@ class DOMNode(MessagePump): def detach(self) -> None: if self._parent and isinstance(self._parent, DOMNode): self._parent.children._remove(self) - print(self.parent.children) self._detach() - print("DETATCH", self) - print(self.app._registry) def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. diff --git a/src/textual/renderables/align.py b/src/textual/renderables/align.py index cb582c137..0e2f86d91 100644 --- a/src/textual/renderables/align.py +++ b/src/textual/renderables/align.py @@ -5,9 +5,9 @@ from rich.measure import Measurement from rich.segment import Segment from rich.style import Style -from ..geometry import Size -from ..css.types import AlignHorizontal, AlignVertical from .._segment_tools import align_lines +from ..css.types import AlignHorizontal, AlignVertical +from ..geometry import Size class Align: diff --git a/src/textual/widget.py b/src/textual/widget.py index e003a6b74..dbac8fe2b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,5 +1,6 @@ from __future__ import annotations +from itertools import islice from fractions import Fraction from operator import attrgetter from typing import ( @@ -989,18 +990,6 @@ class Widget(DOMNode): else: renderable = Styled(renderable, rich_style) - styles = self.styles - horizontal, vertical = styles.content_align - # TODO: This changes the shape of the renderable and breaks alignment - # We need custom functionality that doesn't measure the renderable again - renderable = Align( - renderable, - self.size, - rich_style, - horizontal, - vertical, - ) - return renderable def watch_mouse_over(self, value: bool) -> None: @@ -1043,7 +1032,30 @@ class Widget(DOMNode): options = self.console.options.update_dimensions(width, height).update( highlight=False ) - lines = self.console.render_lines(renderable, options) + + segments = self.console.render(renderable, options) + lines = list( + islice( + Segment.split_and_crop_lines( + segments, width, include_new_lines=False, pad=False + ), + None, + height, + ) + ) + + styles = self.styles + align_horizontal, align_vertical = styles.content_align + lines = list( + align_lines( + lines, + Style(), + self.size, + align_horizontal, + align_vertical, + ) + ) + self._render_cache = RenderCache(self.size, lines) self._dirty_regions.clear() diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 7d617e81f..eb3359dbb 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -1,7 +1,7 @@ from __future__ import annotations from rich.console import RenderableType -from rich.style import Style + from ..widget import Widget From c915c38418cc9a2864f446e8cb2c46bd608f5b76 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 20:14:35 +0100 Subject: [PATCH 09/21] fix flicker on screen resume --- src/textual/app.py | 2 +- src/textual/widget.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b8a084f31..ca432e77e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1087,7 +1087,7 @@ class App(Generic[ReturnType], DOMNode): if isinstance(event, events.Mount): screen = Screen(id="_default") self._register(self, screen) - self.push_screen(screen) + self._screen_stack.append(screen) await super().on_event(event) elif isinstance(event, events.InputEvent) and not event.is_forwarded: diff --git a/src/textual/widget.py b/src/textual/widget.py index dbac8fe2b..3ee483d19 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1139,7 +1139,8 @@ class Widget(DOMNode): Returns: RenderableType: Any renderable """ - return "" if self.is_container else self.css_identifier_styled + render = "" if self.is_container else self.css_identifier_styled + return render async def action(self, action: str, *params) -> None: await self.app.action(action, self) From cafff08e23741af384107d20cb8612f8948ac3e4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 20:17:08 +0100 Subject: [PATCH 10/21] sandbox fix --- sandbox/will/center.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sandbox/will/center.py b/sandbox/will/center.py index e2941f970..b5f5a08b2 100644 --- a/sandbox/will/center.py +++ b/sandbox/will/center.py @@ -5,12 +5,12 @@ from textual.widgets import Static class CenterApp(App): CSS = """ - Screen { + CenterApp Screen { layout: center; overflow: auto auto; } - Static { + CenterApp Static { border: wide $primary; background: $panel; width: 50; From efe0342a6f040ca135b74c5414181cb57608a01b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 Aug 2022 20:39:43 +0100 Subject: [PATCH 11/21] docstring --- src/textual/app.py | 92 +++++++++++++++++++++++++-------------------- tests/test_focus.py | 2 + 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index ca432e77e..d5ef2331d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -639,51 +639,18 @@ class App(Generic[ReturnType], DOMNode): self._register(self, next_screen) return next_screen - def push_screen(self, screen: Screen | str) -> None: - """Push a new screen on the screen stack. + def _replace_screen(self, screen: Screen, remove: bool | None = None) -> Screen: + """Handle the replaced screen. Args: - screen (Screen | str): A Screen instance or an id. + screen (Screen): A screen object. + remove (bool | None): Remove replaced screen if True. Don't remove if False. + If None, remove screens not in self.SCREENS. Returns: - Screen: Newly active screen. + Screen: The replaced screen """ - next_screen = self._get_screen(screen) - self._screen_stack.append(next_screen) - self.screen.post_message_no_wait(events.ScreenResume(self)) - - def switch_screen(self, screen: Screen | str) -> Screen: - """Switch to a another screen. - - Args: - screen (Screen | str): A screen instance or a named of a screen. - - Returns: - Screen: The previous screen object. - """ - next_screen = self._get_screen(screen) - current_screen = self._screen_stack.pop() - current_screen.post_message_no_wait(events.ScreenSuspend(self)) - self._screen_stack.append(next_screen) - self.screen.post_message_no_wait(events.ScreenResume(self)) - return current_screen - - def pop_screen(self, remove: bool | None = None) -> Screen: - """Pop the current screen from the stack, and switch to the previous screen. - - Returns: - Screen: The screen that was replaced. - """ - screen_stack = self._screen_stack - if len(screen_stack) <= 1: - raise ScreenStackError( - "Can't pop screen; there must be at least one screen on the stack" - ) - screen = screen_stack.pop() screen.post_message_no_wait(events.ScreenSuspend(self)) - self.screen._screen_resized(self.size) - self.screen.post_message_no_wait(events.ScreenResume(self)) - if remove is None: if screen not in self.SCREENS.values(): screen.remove() @@ -696,9 +663,54 @@ class App(Generic[ReturnType], DOMNode): screen.remove() else: screen.detach() - return screen + def push_screen(self, screen: Screen | str) -> None: + """Push a new screen on the screen stack. + + Args: + screen (Screen | str): A Screen instance or an id. + + """ + next_screen = self._get_screen(screen) + self._screen_stack.append(next_screen) + self.screen.post_message_no_wait(events.ScreenResume(self)) + + def switch_screen(self, screen: Screen | str, remove: bool | None) -> Screen: + """Switch to a another screen. + + Args: + screen (Screen | str): A screen instance or a named of a screen. + remove (bool | None): Remove replaced screen if True. Don't remove if False. + If None, remove screens not in self.SCREENS. + + Returns: + Screen: The previous screen object. + """ + next_screen = self._get_screen(screen) + previous_screen = self._replace_screen(self._screen_stack.pop(), remove=remove) + self._screen_stack.append(next_screen) + self.screen.post_message_no_wait(events.ScreenResume(self)) + return previous_screen + + def pop_screen(self, remove: bool | None = None) -> Screen: + """Pop the current screen from the stack, and switch to the previous screen. + + Returns: + Screen: The screen that was replaced. + remove (bool | None): Remove replaced screen if True. Don't remove if False. + If None, remove screens not in self.SCREENS. + """ + screen_stack = self._screen_stack + if len(screen_stack) <= 1: + raise ScreenStackError( + "Can't pop screen; there must be at least one screen on the stack" + ) + previous_screen = self._replace_screen(screen_stack.pop(), remove) + self.screen._screen_resized(self.size) + self.screen.post_message_no_wait(events.ScreenResume(self)) + return previous_screen + def set_focus(self, widget: Widget | None) -> None: """Focus (or unfocus) a widget. A focused widget will receive key events first. diff --git a/tests/test_focus.py b/tests/test_focus.py index dfe8dad8a..8d62ab901 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -14,6 +14,7 @@ class NonFocusable(Widget, can_focus=False, can_focus_children=False): async def test_focus_chain(): app = App() + app._set_active() app.push_screen(Screen()) # Check empty focus chain @@ -34,6 +35,7 @@ async def test_focus_chain(): async def test_focus_next_and_previous(): app = App() + app._set_active() app.push_screen(Screen()) app.screen.add_children( Focusable(id="foo"), From 97a9619d5904503e9f522717da096556f0a785ef Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 08:46:06 +0100 Subject: [PATCH 12/21] revised screens api --- poetry.lock | 11 ++- pyproject.toml | 1 + sandbox/will/screens.py | 16 ++--- src/textual/app.py | 119 ++++++++++++++++++++++++--------- src/textual/screen.py | 1 + src/textual/widgets/_pretty.py | 2 +- 6 files changed, 107 insertions(+), 43 deletions(-) diff --git a/poetry.lock b/poetry.lock index bb51b8028..0e2803c6b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -444,6 +444,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "nodeenv" version = "1.7.0" @@ -780,7 +788,7 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "2d0f99d7fb563eb0b34cda9542ecf87c35cf5944a67510625969ec7b046b6d03" +content-hash = "61db56567f708cd9ca1c27f0e4a4b4aa3dd808fc8411f80967a90995d7fdd8c8" [metadata.files] aiohttp = [ @@ -1275,6 +1283,7 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +nanoid = [] nodeenv = [ {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, diff --git a/pyproject.toml b/pyproject.toml index df35c59e5..71eb976e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ typing-extensions = { version = "^4.0.0", python = "<3.8" } aiohttp = { version = "^3.8.1", optional = true } click = {version = "8.1.2", optional = true} msgpack = { version = "^1.0.3", optional = true } +nanoid = "^2.0.0" [tool.poetry.extras] dev = ["aiohttp", "click", "msgpack"] diff --git a/sandbox/will/screens.py b/sandbox/will/screens.py index 750035abb..c3f655fee 100644 --- a/sandbox/will/screens.py +++ b/sandbox/will/screens.py @@ -17,7 +17,7 @@ class NewScreen(Screen): yield Footer() def on_screen_resume(self): - self.query("*").refresh() + self.query_one(Pretty).update(self.app.screen_stack) class ScreenApp(App): @@ -41,17 +41,16 @@ class ScreenApp(App): } """ - SCREENS = { - "1": NewScreen("screen 1"), - "2": NewScreen("screen 2"), - "3": NewScreen("screen 3"), - } - def compose(self) -> ComposeResult: yield Static("On Screen 1") yield Footer() def on_mount(self) -> None: + + self.install_screen(NewScreen("Screen1"), name="1") + self.install_screen(NewScreen("Screen2"), name="2") + self.install_screen(NewScreen("Screen3"), name="3") + self.bind("1", "switch_screen('1')", description="Screen 1") self.bind("2", "switch_screen('2')", description="Screen 2") self.bind("3", "switch_screen('3')", description="Screen 3") @@ -63,4 +62,5 @@ class ScreenApp(App): app = ScreenApp() -app.run() +if __name__ == "__main__": + app.run() diff --git a/src/textual/app.py b/src/textual/app.py index d5ef2331d..19a5bf7dd 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -21,7 +21,7 @@ from typing import ( TypeVar, TYPE_CHECKING, ) -from weakref import WeakSet +from weakref import WeakSet, WeakValueDictionary from ._ansi_sequences import SYNC_START, SYNC_END @@ -30,6 +30,7 @@ if sys.version_info >= (3, 8): else: from typing_extensions import Literal # pragma: no cover +import nanoid import rich import rich.repr from rich.console import Console, RenderableType @@ -113,7 +114,11 @@ class ActionError(Exception): pass -class ScreenStackError(Exception): +class ScreenError(Exception): + pass + + +class ScreenStackError(ScreenError): pass @@ -216,6 +221,10 @@ class App(Generic[ReturnType], DOMNode): self._registry: WeakSet[DOMNode] = WeakSet() + self._installed_screens: WeakValueDictionary[ + str, Screen + ] = WeakValueDictionary() + self.devtools = DevtoolsClient() self._return_value: ReturnType | None = None @@ -614,7 +623,14 @@ class App(Generic[ReturnType], DOMNode): for widget in widgets: self._register(self.screen, widget) - def _get_screen(self, screen: Screen | str) -> Screen: + def is_screen_installed(self, screen: Screen | str) -> bool: + """Check if a given screen has been installed.""" + if isinstance(screen, str): + return screen in self._installed_screens + else: + return screen in self._installed_screens.values() + + def get_screen(self, screen: Screen | str) -> Screen: """Get a screen and ensure it is registered. Args: @@ -628,41 +644,30 @@ class App(Generic[ReturnType], DOMNode): """ if isinstance(screen, str): try: - next_screen = self.SCREENS[screen] + next_screen = self._installed_screens[screen] except KeyError: - raise KeyError( - "No screen called {screen!r} found in {self.__class__}.SCREENS" - ) from None + raise KeyError("No screen called {screen!r} installed") from None else: next_screen = screen if not next_screen.is_running: self._register(self, next_screen) return next_screen - def _replace_screen(self, screen: Screen, remove: bool | None = None) -> Screen: + def _replace_screen(self, screen: Screen) -> Screen: """Handle the replaced screen. Args: screen (Screen): A screen object. - remove (bool | None): Remove replaced screen if True. Don't remove if False. - If None, remove screens not in self.SCREENS. Returns: - Screen: The replaced screen + Screen: The screen that was replaced. + """ screen.post_message_no_wait(events.ScreenSuspend(self)) - if remove is None: - if screen not in self.SCREENS.values(): - screen.remove() - else: - screen.detach() - else: - if remove: - if screen in self.SCREENS.values(): - raise ScreenStackError("Can't remove screen set in App.SCREENS") - screen.remove() - else: - screen.detach() + self.log(f"{screen} SUSPENDED") + if not self.is_screen_installed(screen) and screen not in self._screen_stack: + screen.remove() + self.log(f"{screen} REMOVED") return screen def push_screen(self, screen: Screen | str) -> None: @@ -672,43 +677,91 @@ class App(Generic[ReturnType], DOMNode): screen (Screen | str): A Screen instance or an id. """ - next_screen = self._get_screen(screen) + next_screen = self.get_screen(screen) self._screen_stack.append(next_screen) self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log(f"{self.screen} is current (PUSHED)") - def switch_screen(self, screen: Screen | str, remove: bool | None) -> Screen: + def switch_screen(self, screen: Screen | str) -> Screen: """Switch to a another screen. Args: screen (Screen | str): A screen instance or a named of a screen. - remove (bool | None): Remove replaced screen if True. Don't remove if False. - If None, remove screens not in self.SCREENS. Returns: Screen: The previous screen object. """ - next_screen = self._get_screen(screen) - previous_screen = self._replace_screen(self._screen_stack.pop(), remove=remove) + previous_screen = self._replace_screen(self._screen_stack.pop()) + next_screen = self.get_screen(screen) self._screen_stack.append(next_screen) self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log(f"{self.screen} is current (SWITCHED)") return previous_screen - def pop_screen(self, remove: bool | None = None) -> Screen: + def install_screen(self, screen: Screen, name: str | None = None) -> str: + """Install a screen. + + Args: + screen (Screen): Screen to install. + name (str | None, optional): Unique name of screen or None to auto-generate. + Defaults to None. + + Raises: + ScreenError: If the screen can't be installed. + + Returns: + str: The name of the screen + """ + if name is None: + name = nanoid.generate() + if name in self._installed_screens: + raise ScreenError(f"Can't install screen; {name!r} is already registered") + if screen in self._installed_screens.values(): + raise ScreenError( + "Can't install screen; {screen!r} has already been installed" + ) + self._installed_screens[name] = screen + self.get_screen(name) # Ensures screen is running + self.log(f"{screen} INSTALLED name={name!r}") + return name + + def uninstall_screen(self, screen: Screen | str) -> str | None: + """Uninstall a screen. If the screen was not previously installed then this + method is a null-op. + + Args: + screen (Screen | str): The screen to uninstall or the name of a installed screen. + + Returns: + str | None: The name of the screen that was uninstalled, or None if no screen was uninstalled. + """ + if isinstance(screen, str): + uninstalled_screen = self._installed_screens.pop(screen) + self.log(f"{uninstalled_screen} UNINSTALLED name={screen!r}") + return screen + else: + for name, installed_screen in self._installed_screens.items(): + if installed_screen is screen: + self._installed_screens.pop(name) + self.log(f"{screen} UNINSTALLED name={name!r}") + return name + return None + + def pop_screen(self) -> Screen: """Pop the current screen from the stack, and switch to the previous screen. Returns: Screen: The screen that was replaced. - remove (bool | None): Remove replaced screen if True. Don't remove if False. - If None, remove screens not in self.SCREENS. """ screen_stack = self._screen_stack if len(screen_stack) <= 1: raise ScreenStackError( "Can't pop screen; there must be at least one screen on the stack" ) - previous_screen = self._replace_screen(screen_stack.pop(), remove) + previous_screen = self._replace_screen(screen_stack.pop()) self.screen._screen_resized(self.size) self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log(f"{self.screen} is active") return previous_screen def set_focus(self, widget: Widget | None) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index b427b9e89..d81297ca9 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -232,6 +232,7 @@ class Screen(Widget): def _on_screen_resume(self) -> None: """Called by the App""" + size = self.app.size self._refresh_layout(size, full=True) diff --git a/src/textual/widgets/_pretty.py b/src/textual/widgets/_pretty.py index 44b64ff0b..3d4369e52 100644 --- a/src/textual/widgets/_pretty.py +++ b/src/textual/widgets/_pretty.py @@ -33,4 +33,4 @@ class Pretty(Widget): def update(self, object: Any) -> None: self._renderable = PrettyRenderable(object) - self.refresh() + self.refresh(layout=True) From 78c6c895442a142c13afc602e8c41d7797756c7f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 09:17:39 +0100 Subject: [PATCH 13/21] added tests for screens --- src/textual/app.py | 20 +++++-------- tests/test_screens.py | 66 +++++++++++++++++++++++++++++++++++++++++++ tests/test_view.py | 6 ---- 3 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 tests/test_screens.py delete mode 100644 tests/test_view.py diff --git a/src/textual/app.py b/src/textual/app.py index 19a5bf7dd..80d12510c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -from datetime import datetime import inspect import io import os @@ -9,9 +8,11 @@ import platform import sys import warnings from contextlib import redirect_stdout +from datetime import datetime from pathlib import PurePath from time import perf_counter from typing import ( + TYPE_CHECKING, Any, Generic, Iterable, @@ -19,11 +20,10 @@ from typing import ( TextIO, Type, TypeVar, - TYPE_CHECKING, ) from weakref import WeakSet, WeakValueDictionary -from ._ansi_sequences import SYNC_START, SYNC_END +from ._ansi_sequences import SYNC_END, SYNC_START if sys.version_info >= (3, 8): from typing import Literal @@ -39,30 +39,24 @@ from rich.protocol import is_renderable from rich.segment import Segments from rich.traceback import Traceback -from . import actions -from . import events -from . import log -from . import messages +from . import actions, events, log, messages from ._animator import Animator from ._callback import invoke from ._context import active_app -from ._event_broker import extract_handler_actions, NoHandler +from ._event_broker import NoHandler, extract_handler_actions from .binding import Bindings, NoBinding -from .css.stylesheet import Stylesheet from .css.query import NoMatchingNodesError +from .css.stylesheet import Stylesheet from .design import ColorSystem from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog from .devtools.redirect_output import StdoutRedirector from .dom import DOMNode from .driver import Driver -from .features import parse_features, FeatureFlag +from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor from .geometry import Offset, Region, Size -from .message_pump import MessagePump from .reactive import Reactive from .renderables.blank import Blank -from ._profile import timer - from .screen import Screen from .widget import Widget diff --git a/tests/test_screens.py b/tests/test_screens.py new file mode 100644 index 000000000..959b27da3 --- /dev/null +++ b/tests/test_screens.py @@ -0,0 +1,66 @@ +import pytest + +from textual.app import App, ScreenStackError +from textual.screen import Screen + + +@pytest.mark.asyncio +async def test_screens(): + + app = App() + app._set_active() + + assert not app._installed_screens + + screen1 = Screen(name="screen1") + screen2 = Screen(name="screen2") + screen3 = Screen(name="screen3") + + # installs screens + app.install_screen(screen1, "screen1") + app.install_screen(screen2, "screen2") + + # Check they are installed + assert app.is_screen_installed("screen1") + assert app.is_screen_installed("screen2") + + assert app.get_screen("screen1") is screen1 + with pytest.raises(KeyError): + app.get_screen("foo") + + # Check screen3 is not installed + assert not app.is_screen_installed("screen3") + + # Installs screen3 + app.install_screen(screen3, "screen3") + # Confirm installed + assert app.is_screen_installed("screen3") + + # Check screen stack is empty + assert app.screen_stack == [] + # Push a screen + app.push_screen("screen1") + # Check it is on the stack + assert app.screen_stack == [screen1] + # Check it is current + assert app.screen is screen1 + + # Switch to another screen + app.switch_screen("screen2") + # Check it has changed the stack and that it is current + assert app.screen_stack == [screen2] + assert app.screen is screen2 + + # Push another screen + app.push_screen("screen3") + assert app.screen_stack == [screen2, screen3] + assert app.screen is screen3 + + # Pop a screen + assert app.pop_screen() is screen3 + assert app.screen is screen2 + assert app.screen_stack == [screen2] + + # Check we can't pop last screen + with pytest.raises(ScreenStackError): + app.pop_screen() diff --git a/tests/test_view.py b/tests/test_view.py deleted file mode 100644 index 7aa51d33a..000000000 --- a/tests/test_view.py +++ /dev/null @@ -1,6 +0,0 @@ -import pytest - -from textual.layouts.grid import GridLayout -from textual.layouts.horizontal import HorizontalLayout -from textual.layouts.vertical import VerticalLayout -from textual.screen import Screen From 40374984ed20eb3b384d608bdaf8dbbe09b3ff1d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 09:20:31 +0100 Subject: [PATCH 14/21] test --- src/textual/app.py | 5 ++++- tests/test_screens.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 80d12510c..fe288a2a5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -418,7 +418,10 @@ class App(Generic[ReturnType], DOMNode): @property def screen(self) -> Screen: - return self._screen_stack[-1] + try: + return self._screen_stack[-1] + except IndexError: + raise ScreenStackError("No screens on stack") from None @property def size(self) -> Size: diff --git a/tests/test_screens.py b/tests/test_screens.py index 959b27da3..7ab342ad4 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -10,6 +10,9 @@ async def test_screens(): app = App() app._set_active() + with pytest.raises(ScreenStackError): + app.screen + assert not app._installed_screens screen1 = Screen(name="screen1") From 4b596352d9fcea990747c06abed0da1832f110b0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 09:33:46 +0100 Subject: [PATCH 15/21] removed cruft --- src/textual/app.py | 11 +++++++++-- src/textual/dom.py | 5 ----- src/textual/message_pump.py | 4 ---- tests/test_screens.py | 10 ++++++++++ 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index fe288a2a5..d60aecde1 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -733,10 +733,17 @@ class App(Generic[ReturnType], DOMNode): str | None: The name of the screen that was uninstalled, or None if no screen was uninstalled. """ if isinstance(screen, str): - uninstalled_screen = self._installed_screens.pop(screen) - self.log(f"{uninstalled_screen} UNINSTALLED name={screen!r}") + if screen not in self._installed_screens: + return None + uninstall_screen = self._installed_screens[screen] + if uninstall_screen in self._screen_stack: + raise ScreenStackError("Can't uninstall screen in screen stack") + del self._installed_screens[screen] + self.log(f"{uninstall_screen} UNINSTALLED name={screen!r}") return screen else: + if screen in self._screen_stack: + raise ScreenStackError("Can't uninstall screen in screen stack") for name, installed_screen in self._installed_screens.items(): if installed_screen is screen: self._installed_screens.pop(name) diff --git a/src/textual/dom.py b/src/textual/dom.py index a5d2ac4f5..f1a0fc59f 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -445,11 +445,6 @@ class DOMNode(MessagePump): """The children which don't have display: none set.""" return [child for child in self.children if child.display] - def detach(self) -> None: - if self._parent and isinstance(self._parent, DOMNode): - self._parent.children._remove(self) - self._detach() - def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index b263fea83..02583de9b 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -130,10 +130,6 @@ class MessagePump(metaclass=MessagePumpMeta): """ self._parent = parent - def _detach(self) -> None: - """Unset the parent, removing it from the tree.""" - self._parent = None - def check_message_enabled(self, message: Message) -> bool: return type(message) not in self._disabled_messages diff --git a/tests/test_screens.py b/tests/test_screens.py index 7ab342ad4..73ee7f1e4 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -64,6 +64,16 @@ async def test_screens(): assert app.screen is screen2 assert app.screen_stack == [screen2] + # Uninstall screens + app.uninstall_screen(screen1) + assert not app.is_screen_installed(screen1) + app.uninstall_screen("screen3") + assert not app.is_screen_installed(screen1) + + # Check we can't uninstall a screen on the stack + with pytest.raises(ScreenStackError): + app.uninstall_screen(screen2) + # Check we can't pop last screen with pytest.raises(ScreenStackError): app.pop_screen() From abe87f755ce86d99eb893662976e21af9709f469 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 09:37:28 +0100 Subject: [PATCH 16/21] docstring --- src/textual/_border.py | 3 --- src/textual/renderables/align.py | 9 +++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/textual/_border.py b/src/textual/_border.py index 516951e37..43d70f11d 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -263,10 +263,7 @@ class Border: else: render_options = options.update_width(width) - print("LINES", self.renderable) - print(render_options) lines = console.render_lines(self.renderable, render_options) - if self.outline: self._crop_renderable(lines, options.max_width) diff --git a/src/textual/renderables/align.py b/src/textual/renderables/align.py index 0e2f86d91..3aa2442ab 100644 --- a/src/textual/renderables/align.py +++ b/src/textual/renderables/align.py @@ -19,6 +19,15 @@ class Align: horizontal: AlignHorizontal, vertical: AlignVertical, ) -> None: + """Align a child renderable + + Args: + renderable (RenderableType): Renderable to align. + size (Size): Size of container. + style (Style): Style of any padding. + horizontal (AlignHorizontal): Horizontal alignment. + vertical (AlignVertical): Vertical alignment. + """ self.renderable = renderable self.size = size self.style = style From cb7d8060401557284372284e85024d99205c8ecc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 09:47:54 +0100 Subject: [PATCH 17/21] added quit_after --- e2e_tests/test_apps/basic.py | 2 +- src/textual/app.py | 14 ++++++++++++-- src/textual/screen.py | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/e2e_tests/test_apps/basic.py b/e2e_tests/test_apps/basic.py index dee825f0e..514b3c4a1 100644 --- a/e2e_tests/test_apps/basic.py +++ b/e2e_tests/test_apps/basic.py @@ -177,7 +177,7 @@ class BasicApp(App, css_path="basic.css"): app = BasicApp() if __name__ == "__main__": - app.run() + app.run(quit_after=1) # from textual.geometry import Region # from textual.color import Color diff --git a/src/textual/app.py b/src/textual/app.py index d60aecde1..74ea4aa6f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -539,10 +539,20 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) - def run(self) -> ReturnType | None: - """The entry point to run a Textual app.""" + def run(self, quit_after: float | None = None) -> ReturnType | None: + """The main entry point for apps. + + Args: + quit_after (float | None, optional): Quit after a given number of seconds, or None + to run forever. Defaults to None. + + Returns: + ReturnType | None: _description_ + """ async def run_app() -> None: + if quit_after is not None: + self.set_timer(quit_after, self.shutdown) await self.process_messages() if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: diff --git a/src/textual/screen.py b/src/textual/screen.py index d81297ca9..b8f6af01f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -53,6 +53,7 @@ class Screen(Widget): @property def is_current(self) -> bool: + """Check if this screen is current (i.e. visible to user).""" return self.app.screen is self @property From d7656abb82eab271f49663456b8e98e0dac2f37e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 09:55:54 +0100 Subject: [PATCH 18/21] fix e2e --- e2e_tests/sandbox_basic_test.py | 2 +- e2e_tests/test_apps/basic.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e_tests/sandbox_basic_test.py b/e2e_tests/sandbox_basic_test.py index 888e8fa10..6cd87ddac 100644 --- a/e2e_tests/sandbox_basic_test.py +++ b/e2e_tests/sandbox_basic_test.py @@ -34,7 +34,7 @@ def launch_sandbox_script(python_file_name: str) -> None: thread = threading.Thread( - target=launch_sandbox_script, args=(target_script_name,), daemon=True + target=launch_sandbox_script, args=(target_script_name,), daemon=False ) thread.start() diff --git a/e2e_tests/test_apps/basic.py b/e2e_tests/test_apps/basic.py index 514b3c4a1..e96cffcbd 100644 --- a/e2e_tests/test_apps/basic.py +++ b/e2e_tests/test_apps/basic.py @@ -177,7 +177,7 @@ class BasicApp(App, css_path="basic.css"): app = BasicApp() if __name__ == "__main__": - app.run(quit_after=1) + app.run(quit_after=2) # from textual.geometry import Region # from textual.color import Color From 11191b2109cdc9bf43aa871e04f10e1723135ee3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 10:06:25 +0100 Subject: [PATCH 19/21] fix segmentation fault --- src/textual/app.py | 4 ++-- src/textual/widget.py | 1 - tests/test_screens.py | 2 ++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 74ea4aa6f..e587f297a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1071,8 +1071,8 @@ class App(Generic[ReturnType], DOMNode): async def shutdown(self): await self._disconnect_devtools() driver = self._driver - assert driver is not None - driver.disable_input() + if driver is not None: + driver.disable_input() await self.close_messages() def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 3ee483d19..1922d9c41 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -37,7 +37,6 @@ from .geometry import Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message from .reactive import Reactive, watch -from .renderables.align import Align if TYPE_CHECKING: diff --git a/tests/test_screens.py b/tests/test_screens.py index 73ee7f1e4..97d681dad 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -77,3 +77,5 @@ async def test_screens(): # Check we can't pop last screen with pytest.raises(ScreenStackError): app.pop_screen() + + await app.shutdown() From d5b3b752c68c30936c21ff91b6a2672cc54cec9b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 10:12:37 +0100 Subject: [PATCH 20/21] skip on py310 --- tests/test_screens.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_screens.py b/tests/test_screens.py index 97d681dad..9a9365aa2 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -1,9 +1,18 @@ +import sys + import pytest from textual.app import App, ScreenStackError from textual.screen import Screen +skip_py310 = pytest.mark.skipif( + sys.version_info.minor == 10 and sys.version_info.major == 3, + reason="segfault on py3.10", +) + + +@skip_py310 @pytest.mark.asyncio async def test_screens(): @@ -78,4 +87,7 @@ async def test_screens(): with pytest.raises(ScreenStackError): app.pop_screen() + screen1.remove() + screen2.remove() + screen3.remove() await app.shutdown() From e2d78ee88f3cfe39069756521a5957b12199139f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 11:59:06 +0100 Subject: [PATCH 21/21] review changes --- src/textual/app.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index e587f297a..9913e54d6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -631,17 +631,26 @@ class App(Generic[ReturnType], DOMNode): self._register(self.screen, widget) def is_screen_installed(self, screen: Screen | str) -> bool: - """Check if a given screen has been installed.""" + """Check if a given screen has been installed. + + Args: + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). + + Returns: + bool: True if the screen is currently installed, + """ if isinstance(screen, str): return screen in self._installed_screens else: return screen in self._installed_screens.values() def get_screen(self, screen: Screen | str) -> Screen: - """Get a screen and ensure it is registered. + """Get an installed screen. + + If the screen isn't running, it will be registered before it is run. Args: - screen (Screen | str): A screen instance or a named screen. + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). Raises: KeyError: If the named screen doesn't exist. @@ -689,21 +698,19 @@ class App(Generic[ReturnType], DOMNode): self.screen.post_message_no_wait(events.ScreenResume(self)) self.log(f"{self.screen} is current (PUSHED)") - def switch_screen(self, screen: Screen | str) -> Screen: - """Switch to a another screen. + def switch_screen(self, screen: Screen | str) -> None: + """Switch to a another screen by replacing the top of the screen stack with a new screen. Args: - screen (Screen | str): A screen instance or a named of a screen. + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). - Returns: - Screen: The previous screen object. """ - previous_screen = self._replace_screen(self._screen_stack.pop()) - next_screen = self.get_screen(screen) - self._screen_stack.append(next_screen) - self.screen.post_message_no_wait(events.ScreenResume(self)) - self.log(f"{self.screen} is current (SWITCHED)") - return previous_screen + if self.screen is not screen: + self._replace_screen(self._screen_stack.pop()) + next_screen = self.get_screen(screen) + self._screen_stack.append(next_screen) + self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log(f"{self.screen} is current (SWITCHED)") def install_screen(self, screen: Screen, name: str | None = None) -> str: """Install a screen. @@ -722,7 +729,7 @@ class App(Generic[ReturnType], DOMNode): if name is None: name = nanoid.generate() if name in self._installed_screens: - raise ScreenError(f"Can't install screen; {name!r} is already registered") + raise ScreenError(f"Can't install screen; {name!r} is already installed") if screen in self._installed_screens.values(): raise ScreenError( "Can't install screen; {screen!r} has already been installed"