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 dee825f0e..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() + app.run(quit_after=2) # from textual.geometry import Region # from textual.color import Color 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/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/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/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; diff --git a/sandbox/will/screens.py b/sandbox/will/screens.py new file mode 100644 index 000000000..c3f655fee --- /dev/null +++ b/sandbox/will/screens.py @@ -0,0 +1,66 @@ +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_one(Pretty).update(self.app.screen_stack) + + +class NewScreen(Screen): + def compose(self): + yield Pretty(self.app.screen_stack) + yield Footer() + + def on_screen_resume(self): + self.query_one(Pretty).update(self.app.screen_stack) + + +class ScreenApp(App): + CSS = """ + ScreenApp Screen { + background: #111144; + color: white; + + + } + ScreenApp ModalScreen { + background: #114411; + color: white; + + + } + ScreenApp Pretty { + height: auto; + content-align: center middle; + background: white 20%; + } + """ + + 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") + self.bind("s", "modal_screen", description="add screen") + self.bind("escape", "back", description="Go back") + + def action_modal_screen(self) -> None: + self.push_screen(ModalScreen()) + + +app = ScreenApp() +if __name__ == "__main__": + app.run() diff --git a/src/textual/_border.py b/src/textual/_border.py index 99dcc0665..43d70f11d 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -264,7 +264,6 @@ class Border: render_options = options.update_width(width) lines = console.render_lines(self.renderable, render_options) - if self.outline: self._crop_renderable(lines, options.max_width) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index df9455f8e..9863a9b59 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,67 @@ 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) + + 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) + + 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 + + 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 8a843c3fc..9913e54d6 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,16 +20,17 @@ 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 else: from typing_extensions import Literal # pragma: no cover +import nanoid import rich import rich.repr from rich.console import Console, RenderableType @@ -37,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 @@ -112,6 +108,14 @@ class ActionError(Exception): pass +class ScreenError(Exception): + pass + + +class ScreenStackError(ScreenError): + pass + + ReturnType = TypeVar("ReturnType") @@ -126,6 +130,8 @@ class App(Generic[ReturnType], DOMNode): } """ + SCREENS: dict[str, Screen] = {} + CSS_PATH: str | None = None def __init__( @@ -176,7 +182,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) @@ -208,7 +213,12 @@ 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: WeakSet[DOMNode] = WeakSet() + + self._installed_screens: WeakValueDictionary[ + str, Screen + ] = WeakValueDictionary() + self.devtools = DevtoolsClient() self._return_value: ReturnType | None = None @@ -243,6 +253,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. @@ -403,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: @@ -503,6 +521,7 @@ class App(Generic[ReturnType], DOMNode): self, keys: str, action: str, + *, description: str = "", show: bool = True, key_display: str | None = None, @@ -520,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: @@ -601,19 +630,160 @@ class App(Generic[ReturnType], DOMNode): for widget in widgets: self._register(self.screen, widget) - def push_screen(self, screen: Screen | None = None) -> Screen: + def is_screen_installed(self, screen: Screen | str) -> bool: + """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 an installed screen. + + If the screen isn't running, it will be registered before it is run. + + Args: + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). + + Raises: + KeyError: If the named screen doesn't exist. + + Returns: + Screen: A screen instance. + """ + if isinstance(screen, str): + try: + next_screen = self._installed_screens[screen] + except KeyError: + 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) -> Screen: + """Handle the replaced screen. + + Args: + screen (Screen): A screen object. + + Returns: + Screen: The screen that was replaced. + + """ + screen.post_message_no_wait(events.ScreenSuspend(self)) + 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: """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. + + """ + 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) -> None: + """Switch to a another screen by replacing the top of the screen stack with a new screen. + + Args: + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). + + """ + 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. + + 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: - Screen: Newly active screen. + str: The name of the screen """ - new_screen = Screen() if screen is None else screen - self._screen_stack.append(new_screen) - return new_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 installed") + 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): + 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) + 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. + """ + 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()) + 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: """Focus (or unfocus) a widget. A focused widget will receive key events first. @@ -621,7 +791,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 @@ -835,11 +1004,11 @@ 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) - child.set_parent(parent) + self._registry.add(child) + child._attach(parent) child.on_register(self) child.start_messages() return True @@ -862,7 +1031,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) @@ -877,11 +1046,12 @@ 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) - self.registry.discard(widget) + widget._attach(None) + self._registry.discard(widget) async def _disconnect_devtools(self): await self.devtools.disconnect() @@ -893,33 +1063,29 @@ 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)) 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): 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: 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 +1098,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,9 +1171,9 @@ 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) + self._screen_stack.append(screen) await super().on_event(event) elif isinstance(event, events.InputEvent) and not event.is_forwarded: @@ -1104,11 +1271,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 +1290,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 +1313,21 @@ 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_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/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 3220a06b8..f1a0fc59f 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() @@ -467,7 +470,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. @@ -478,10 +481,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/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/message_pump.py b/src/textual/message_pump.py index 71c964de8..02583de9b 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -122,7 +122,12 @@ 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 _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 check_message_enabled(self, message: Message) -> bool: diff --git a/src/textual/renderables/align.py b/src/textual/renderables/align.py new file mode 100644 index 000000000..3aa2442ab --- /dev/null +++ b/src/textual/renderables/align.py @@ -0,0 +1,56 @@ +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 .._segment_tools import align_lines +from ..css.types import AlignHorizontal, AlignVertical +from ..geometry import Size + + +class Align: + def __init__( + self, + renderable: RenderableType, + size: Size, + style: Style, + 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 + 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 5e20862b2..b8f6af01f 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; } @@ -52,6 +51,11 @@ class Screen(Widget): def is_transparent(self) -> bool: return False + @property + def is_current(self) -> bool: + """Check if this screen is current (i.e. visible to user).""" + 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,13 @@ 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..1922d9c41 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 ( @@ -14,7 +15,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 +28,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 @@ -36,6 +38,7 @@ from .layouts.vertical import VerticalLayout from .message import Message from .reactive import Reactive, watch + if TYPE_CHECKING: from .app import App, ComposeResult from .scrollbar import ( @@ -78,7 +81,6 @@ class Widget(DOMNode): scrollbar-size-horizontal: 1; } """ - COMPONENT_CLASSES: ClassVar[set[str]] = set() can_focus: bool = False @@ -121,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) @@ -287,6 +293,7 @@ class Widget(DOMNode): Returns: int: The height of the content. """ + if self.is_container: assert self.layout is not None height = ( @@ -982,15 +989,6 @@ class Widget(DOMNode): else: renderable = Styled(renderable, rich_style) - styles = self.styles - content_align = ( - styles.content_align_horizontal, - styles.content_align_vertical, - ) - if content_align != ("left", "top"): - horizontal, vertical = content_align - renderable = Align(renderable, horizontal, vertical=vertical) - return renderable def watch_mouse_over(self, value: bool) -> None: @@ -1033,7 +1031,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() @@ -1104,6 +1125,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: @@ -1115,7 +1138,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) @@ -1127,7 +1151,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: @@ -1178,9 +1202,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/__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/_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() diff --git a/src/textual/widgets/_pretty.py b/src/textual/widgets/_pretty.py new file mode 100644 index 000000000..3d4369e52 --- /dev/null +++ b/src/textual/widgets/_pretty.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any +from rich.pretty import Pretty as PrettyRenderable + +from ..widget import Widget + + +class Pretty(Widget): + CSS = """ + Static { + height: auto; + } + """ + + def __init__( + self, + object: Any, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__( + 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(layout=True) 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 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"), diff --git a/tests/test_screens.py b/tests/test_screens.py new file mode 100644 index 000000000..9a9365aa2 --- /dev/null +++ b/tests/test_screens.py @@ -0,0 +1,93 @@ +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(): + + app = App() + app._set_active() + + with pytest.raises(ScreenStackError): + app.screen + + 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] + + # 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() + + screen1.remove() + screen2.remove() + screen3.remove() + await app.shutdown() 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