diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb93b504..3085106dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Added + +- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597 + +### Changed + +- `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590 +- Footer now clears key highlight regardless of whether it's in the active screen or not https://github.com/Textualize/textual/issues/2606 +- The default Widget repr no longer displays classes and pseudo-classes (to reduce noise in logs). Add them to your `__rich_repr__` method if needed. https://github.com/Textualize/textual/pull/2623 + +### Removed + +- `Placeholder.reset_color_cycle` + + ## [0.26.0] - 2023-05-20 ### Added diff --git a/docs/examples/styles/width_comparison.py b/docs/examples/styles/width_comparison.py index 2971425b6..f801bde4a 100644 --- a/docs/examples/styles/width_comparison.py +++ b/docs/examples/styles/width_comparison.py @@ -1,6 +1,6 @@ from textual.app import App from textual.containers import Horizontal -from textual.widgets import Placeholder, Label, Static +from textual.widgets import Label, Placeholder, Static class Ruler(Static): @@ -9,7 +9,7 @@ class Ruler(Static): yield Label(ruler_text) -class HeightComparisonApp(App): +class WidthComparisonApp(App): def compose(self): yield Horizontal( Placeholder(id="cells"), # (1)! @@ -25,4 +25,6 @@ class HeightComparisonApp(App): yield Ruler() -app = HeightComparisonApp(css_path="width_comparison.css") +app = WidthComparisonApp(css_path="width_comparison.css") +if __name__ == "__main__": + app.run() diff --git a/docs/widgets/option_list.md b/docs/widgets/option_list.md index a489e6b4f..4a805a399 100644 --- a/docs/widgets/option_list.md +++ b/docs/widgets/option_list.md @@ -87,7 +87,7 @@ tables](https://rich.readthedocs.io/en/latest/tables.html): ## Messages -- [OptionList.OptionHighlight][textual.widgets.OptionList.OptionHighlighted] +- [OptionList.OptionHighlighted][textual.widgets.OptionList.OptionHighlighted] - [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected] Both of the messages above inherit from the common base [`OptionList`][textual.widgets.OptionList.OptionMessage], so refer to its documentation to see what attributes are available. @@ -115,3 +115,8 @@ The option list provides the following component classes: ::: textual.widgets.OptionList options: heading_level: 2 + + +::: textual.widgets.option_list.Option + options: + heading_level: 2 diff --git a/docs/widgets/select.md b/docs/widgets/select.md index 31a36989c..2ef0cf8f6 100644 --- a/docs/widgets/select.md +++ b/docs/widgets/select.md @@ -66,10 +66,10 @@ The following example presents a `Select` with a number of options. ## Reactive attributes -| Name | Type | Default | Description | -| ---------- | -------------------- | ------- | ----------------------------------- | -| `expanded` | `bool` | `False` | True to expand the options overlay. | -| `value` | `SelectType \| None` | `None` | Current value of the Select. | +| Name | Type | Default | Description | +|------------|------------------------|---------|-------------------------------------| +| `expanded` | `bool` | `False` | True to expand the options overlay. | +| `value` | `SelectType` \| `None` | `None` | Current value of the Select. | ## Bindings diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 2688eccc4..afce88686 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -58,6 +58,7 @@ def work( group: str = "default", exit_on_error: bool = True, exclusive: bool = False, + description: str | None = None, ) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator: """A decorator used to create [workers](/guide/workers). @@ -67,6 +68,9 @@ def work( group: A short string to identify a group of workers. exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. exclusive: Cancel all workers in the same group. + description: Readable description of the worker for debugging purposes. + By default, it uses a string representation of the decorated method + and its arguments. """ def decorator( @@ -87,22 +91,25 @@ def work( self = args[0] assert isinstance(self, DOMNode) - try: - positional_arguments = ", ".join(repr(arg) for arg in args[1:]) - keyword_arguments = ", ".join( - f"{name}={value!r}" for name, value in kwargs.items() - ) - tokens = [positional_arguments, keyword_arguments] - worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})" - except Exception: - worker_description = "" + if description is not None: + debug_description = description + else: + try: + positional_arguments = ", ".join(repr(arg) for arg in args[1:]) + keyword_arguments = ", ".join( + f"{name}={value!r}" for name, value in kwargs.items() + ) + tokens = [positional_arguments, keyword_arguments] + debug_description = f"{method.__name__}({', '.join(token for token in tokens if token)})" + except Exception: + debug_description = "" worker = cast( "Worker[ReturnType]", self.run_worker( partial(method, *args, **kwargs), name=name or method.__name__, group=group, - description=worker_description, + description=debug_description, exclusive=exclusive, exit_on_error=exit_on_error, ), diff --git a/src/textual/app.py b/src/textual/app.py index d92c858dc..aca3be101 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -71,7 +71,7 @@ from ._wait import wait_for_idle from ._worker_manager import WorkerManager from .actions import ActionParseResult, SkipAction from .await_remove import AwaitRemove -from .binding import Binding, _Bindings +from .binding import Binding, BindingType, _Bindings from .css.query import NoMatches from .css.stylesheet import Stylesheet from .design import ColorSystem @@ -159,6 +159,38 @@ class ScreenStackError(ScreenError): """Raised when trying to manipulate the screen stack incorrectly.""" +class ModeError(Exception): + """Base class for exceptions related to modes.""" + + +class InvalidModeError(ModeError): + """Raised if there is an issue with a mode name.""" + + +class UnknownModeError(ModeError): + """Raised when attempting to use a mode that is not known.""" + + +class ActiveModeError(ModeError): + """Raised when attempting to remove the currently active mode.""" + + +class ModeError(Exception): + """Base class for exceptions related to modes.""" + + +class InvalidModeError(ModeError): + """Raised if there is an issue with a mode name.""" + + +class UnknownModeError(ModeError): + """Raised when attempting to use a mode that is not known.""" + + +class ActiveModeError(ModeError): + """Raised when attempting to remove the currently active mode.""" + + class CssPathError(Exception): """Raised when supplied CSS path(s) are invalid.""" @@ -212,6 +244,35 @@ class App(Generic[ReturnType], DOMNode): } """ + MODES: ClassVar[dict[str, str | Screen | Callable[[], Screen]]] = {} + """Modes associated with the app and their base screens. + + The base screen is the screen at the bottom of the mode stack. You can think of + it as the default screen for that stack. + The base screens can be names of screens listed in [SCREENS][textual.app.App.SCREENS], + [`Screen`][textual.screen.Screen] instances, or callables that return screens. + + Example: + ```py + class HelpScreen(Screen[None]): + ... + + class MainAppScreen(Screen[None]): + ... + + class MyApp(App[None]): + MODES = { + "default": "main", + "help": HelpScreen, + } + + SCREENS = { + "main": MainAppScreen, + } + + ... + ``` + """ SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {} """Screens associated with the app for the lifetime of the app.""" _BASE_PATH: str | None = None @@ -230,7 +291,9 @@ class App(Generic[ReturnType], DOMNode): To update the sub-title while the app is running, you can set the [sub_title][textual.app.App.sub_title] attribute. """ - BINDINGS = [Binding("ctrl+c", "quit", "Quit", show=False, priority=True)] + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("ctrl+c", "quit", "Quit", show=False, priority=True) + ] title: Reactive[str] = Reactive("", compute=False) sub_title: Reactive[str] = Reactive("", compute=False) @@ -294,7 +357,10 @@ class App(Generic[ReturnType], DOMNode): self._workers = WorkerManager(self) self.error_console = Console(markup=False, stderr=True) self.driver_class = driver_class or self.get_driver_class() - self._screen_stack: list[Screen] = [] + self._screen_stacks: dict[str, list[Screen]] = {"_default": []} + """A stack of screens per mode.""" + self._current_mode: str = "_default" + """The current mode the app is in.""" self._sync_available = False self.mouse_over: Widget | None = None @@ -526,7 +592,19 @@ class App(Generic[ReturnType], DOMNode): Returns: A snapshot of the current state of the screen stack. """ - return self._screen_stack.copy() + return self._screen_stacks[self._current_mode].copy() + + @property + def _screen_stack(self) -> list[Screen]: + """A reference to the current screen stack. + + Note: + Consider using [`screen_stack`][textual.app.App.screen_stack] instead. + + Returns: + A reference to the current screen stack. + """ + return self._screen_stacks[self._current_mode] def exit( self, result: ReturnType | None = None, message: RenderableType | None = None @@ -674,6 +752,8 @@ class App(Generic[ReturnType], DOMNode): """ try: return self._screen_stack[-1] + except KeyError: + raise UnknownModeError(f"No known mode {self._current_mode!r}") from None except IndexError: raise ScreenStackError("No screens on stack") from None @@ -1319,6 +1399,88 @@ class App(Generic[ReturnType], DOMNode): """ return self.mount(*widgets, before=before, after=after) + def _init_mode(self, mode: str) -> None: + """Do internal initialisation of a new screen stack mode.""" + + stack = self._screen_stacks.get(mode, []) + if not stack: + _screen = self.MODES[mode] + if callable(_screen): + screen, _ = self._get_screen(_screen()) + else: + screen, _ = self._get_screen(self.MODES[mode]) + stack.append(screen) + self._screen_stacks[mode] = [screen] + + def switch_mode(self, mode: str) -> None: + """Switch to a given mode. + + Args: + mode: The mode to switch to. + + Raises: + UnknownModeError: If trying to switch to an unknown mode. + """ + if mode not in self.MODES: + raise UnknownModeError(f"No known mode {mode!r}") + + self.screen.post_message(events.ScreenSuspend()) + self.screen.refresh() + + if mode not in self._screen_stacks: + self._init_mode(mode) + self._current_mode = mode + self.screen._screen_resized(self.size) + self.screen.post_message(events.ScreenResume()) + self.log.system(f"{self._current_mode!r} is the current mode") + self.log.system(f"{self.screen} is active") + + def add_mode( + self, mode: str, base_screen: str | Screen | Callable[[], Screen] + ) -> None: + """Adds a mode and its corresponding base screen to the app. + + Args: + mode: The new mode. + base_screen: The base screen associated with the given mode. + + Raises: + InvalidModeError: If the name of the mode is not valid/duplicated. + """ + if mode == "_default": + raise InvalidModeError("Cannot use '_default' as a custom mode.") + elif mode in self.MODES: + raise InvalidModeError(f"Duplicated mode name {mode!r}.") + + self.MODES[mode] = base_screen + + def remove_mode(self, mode: str) -> None: + """Removes a mode from the app. + + Screens that are running in the stack of that mode are scheduled for pruning. + + Args: + mode: The mode to remove. It can't be the active mode. + + Raises: + ActiveModeError: If trying to remove the active mode. + UnknownModeError: If trying to remove an unknown mode. + """ + if mode == self._current_mode: + raise ActiveModeError(f"Can't remove active mode {mode!r}") + elif mode not in self.MODES: + raise UnknownModeError(f"Unknown mode {mode!r}") + else: + del self.MODES[mode] + + if mode not in self._screen_stacks: + return + + stack = self._screen_stacks[mode] + del self._screen_stacks[mode] + for screen in reversed(stack): + self._replace_screen(screen) + def is_screen_installed(self, screen: Screen | str) -> bool: """Check if a given screen has been installed. @@ -1395,7 +1557,9 @@ class App(Generic[ReturnType], DOMNode): self.screen.refresh() screen.post_message(events.ScreenSuspend()) self.log.system(f"{screen} SUSPENDED") - if not self.is_screen_installed(screen) and screen not in self._screen_stack: + if not self.is_screen_installed(screen) and all( + screen not in stack for stack in self._screen_stacks.values() + ): screen.remove() self.log.system(f"{screen} REMOVED") return screen @@ -1496,13 +1660,13 @@ class App(Generic[ReturnType], DOMNode): if screen not in self._installed_screens: return None uninstall_screen = self._installed_screens[screen] - if uninstall_screen in self._screen_stack: + if any(uninstall_screen in stack for stack in self._screen_stacks.values()): raise ScreenStackError("Can't uninstall screen in screen stack") del self._installed_screens[screen] self.log.system(f"{uninstall_screen} UNINSTALLED name={screen!r}") return screen else: - if screen in self._screen_stack: + if any(screen in stack for stack in self._screen_stacks.values()): raise ScreenStackError("Can't uninstall screen in screen stack") for name, installed_screen in self._installed_screens.items(): if installed_screen is screen: @@ -1689,7 +1853,7 @@ class App(Generic[ReturnType], DOMNode): if self.css_monitor: self.set_interval(0.25, self.css_monitor, name="css monitor") - self.log.system("[b green]STARTED[/]", self.css_monitor) + self.log.system("STARTED", self.css_monitor) async def run_process_messages(): """The main message loop, invoke below.""" @@ -1947,12 +2111,12 @@ class App(Generic[ReturnType], DOMNode): async def _close_all(self) -> None: """Close all message pumps.""" - # Close all screens on the stack. - for stack_screen in reversed(self._screen_stack): - if stack_screen._running: - await self._prune_node(stack_screen) - - self._screen_stack.clear() + # Close all screens on all stacks: + for stack in self._screen_stacks.values(): + for stack_screen in reversed(stack): + if stack_screen._running: + await self._prune_node(stack_screen) + stack.clear() # Close pre-defined screens. for screen in self.SCREENS.values(): @@ -2137,7 +2301,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.Compose): - screen = Screen(id="_default") + screen = Screen(id=f"_default") self._register(self, screen) self._screen_stack.append(screen) screen.post_message(events.ScreenResume()) @@ -2549,7 +2713,7 @@ class App(Generic[ReturnType], DOMNode): def _on_terminal_supports_synchronized_output( self, message: messages.TerminalSupportsSynchronizedOutput ) -> None: - log.system("[b green]SynchronizedOutput mode is supported") + log.system("SynchronizedOutput mode is supported") self._sync_available = True def _begin_update(self) -> None: diff --git a/src/textual/events.py b/src/textual/events.py index 879c155b3..59fd42d5a 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -35,9 +35,6 @@ if TYPE_CHECKING: class Event(Message): """The base class for all events.""" - def __rich_repr__(self) -> rich.repr.Result: - yield from () - @rich.repr.auto class Callback(Event, bubble=False, verbose=True): diff --git a/src/textual/widget.py b/src/textual/widget.py index f38993b2a..d1e7bc64f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2532,11 +2532,6 @@ class Widget(DOMNode): yield "id", self.id, None if self.name: yield "name", self.name - if self.classes: - yield "classes", set(self.classes) - pseudo_classes = self.pseudo_classes - if pseudo_classes: - yield "pseudo_classes", set(pseudo_classes) def _get_scrollable_region(self, region: Region) -> Region: """Adjusts the Widget region to accommodate scrollbars. diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 46001882d..dfe993b3b 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -3,7 +3,7 @@ from __future__ import annotations from asyncio import Queue from dataclasses import dataclass from pathlib import Path -from typing import ClassVar, Iterable, Iterator +from typing import Callable, ClassVar, Iterable, Iterator from rich.style import Style from rich.text import Text, TextType @@ -59,6 +59,9 @@ class DirectoryTree(Tree[DirEntry]): } """ + PATH: Callable[[str | Path], Path] = Path + """Callable that returns a fresh path object.""" + class FileSelected(Message, bubble=True): """Posted when a file is selected. @@ -92,7 +95,7 @@ class DirectoryTree(Tree[DirEntry]): """ return self.tree - path: var[str | Path] = var["str | Path"](Path("."), init=False, always_update=True) + path: var[str | Path] = var["str | Path"](PATH("."), init=False, always_update=True) """The path that is the root of the directory tree. Note: @@ -121,7 +124,7 @@ class DirectoryTree(Tree[DirEntry]): self._load_queue: Queue[TreeNode[DirEntry]] = Queue() super().__init__( str(path), - data=DirEntry(Path(path)), + data=DirEntry(self.PATH(path)), name=name, id=id, classes=classes, @@ -141,7 +144,7 @@ class DirectoryTree(Tree[DirEntry]): def reload(self) -> None: """Reload the `DirectoryTree` contents.""" - self.reset(str(self.path), DirEntry(Path(self.path))) + self.reset(str(self.path), DirEntry(self.PATH(self.path))) # Orphan the old queue... self._load_queue = Queue() # ...and replace the old load with a new one. @@ -163,7 +166,7 @@ class DirectoryTree(Tree[DirEntry]): The result will always be a Python `Path` object, regardless of the value given. """ - return Path(path) + return self.PATH(path) def watch_path(self) -> None: """Watch for changes to the `path` of the directory tree. diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index a52e05785..b5e772ab6 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -79,8 +79,7 @@ class Footer(Widget): def _on_leave(self, _: events.Leave) -> None: """Clear any highlight when the mouse leaves the widget""" - if self.screen.is_current: - self.highlight_key = None + self.highlight_key = None def __rich_repr__(self) -> rich.repr.Result: yield from super().__rich_repr__() diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index dfe530543..3b365fa7b 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -753,7 +753,7 @@ class OptionList(ScrollView, can_focus=True): """Get the option with the given ID. Args: - index: The ID of the option to get. + option_id: The ID of the option to get. Returns: The option at with the ID. diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index fb0858aaf..a6ac37302 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -3,10 +3,14 @@ from __future__ import annotations from itertools import cycle +from typing import Iterator +from weakref import WeakKeyDictionary from rich.console import RenderableType from typing_extensions import Literal, Self +from textual.app import App + from .. import events from ..css._error_tools import friendly_list from ..reactive import Reactive, reactive @@ -72,18 +76,13 @@ class Placeholder(Widget): """ # Consecutive placeholders get assigned consecutive colors. - _COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS) + _COLORS: WeakKeyDictionary[App, Iterator[str]] = WeakKeyDictionary() _SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]" variant: Reactive[PlaceholderVariant] = reactive[PlaceholderVariant]("default") _renderables: dict[PlaceholderVariant, str] - @classmethod - def reset_color_cycle(cls) -> None: - """Reset the placeholder background color cycle.""" - cls._COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS) - def __init__( self, label: str | None = None, @@ -113,8 +112,6 @@ class Placeholder(Widget): super().__init__(name=name, id=id, classes=classes) - self.styles.background = f"{next(Placeholder._COLORS)} 50%" - self.variant = self.validate_variant(variant) """The current variant of the placeholder.""" @@ -123,6 +120,13 @@ class Placeholder(Widget): while next(self._variants_cycle) != self.variant: pass + def on_mount(self) -> None: + """Set the color for this placeholder.""" + colors = Placeholder._COLORS.setdefault( + self.app, cycle(_PLACEHOLDER_BACKGROUND_COLORS) + ) + self.styles.background = f"{next(colors)} 50%" + def render(self) -> RenderableType: """Render the placeholder. diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index c0e2e96b9..e39be06d6 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -12292,141 +12292,141 @@ font-weight: 700; } - .terminal-1938916138-matrix { + .terminal-4051010257-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1938916138-title { + .terminal-4051010257-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1938916138-r1 { fill: #c5c8c6 } - .terminal-1938916138-r2 { fill: #e8e0e7 } - .terminal-1938916138-r3 { fill: #eae3e5 } - .terminal-1938916138-r4 { fill: #ede6e6 } - .terminal-1938916138-r5 { fill: #efe9e4 } - .terminal-1938916138-r6 { fill: #efeedf } - .terminal-1938916138-r7 { fill: #e9eee5 } - .terminal-1938916138-r8 { fill: #e4eee8 } - .terminal-1938916138-r9 { fill: #e2edeb } - .terminal-1938916138-r10 { fill: #dfebed } - .terminal-1938916138-r11 { fill: #ddedf9 } + .terminal-4051010257-r1 { fill: #c5c8c6 } + .terminal-4051010257-r2 { fill: #e8e0e7 } + .terminal-4051010257-r3 { fill: #eae3e5 } + .terminal-4051010257-r4 { fill: #ede6e6 } + .terminal-4051010257-r5 { fill: #efe9e4 } + .terminal-4051010257-r6 { fill: #efeedf } + .terminal-4051010257-r7 { fill: #e9eee5 } + .terminal-4051010257-r8 { fill: #e4eee8 } + .terminal-4051010257-r9 { fill: #e2edeb } + .terminal-4051010257-r10 { fill: #dfebed } + .terminal-4051010257-r11 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeightComparisonApp + WidthComparisonApp - + - - - - - - - - - - - - - #cells#percent#w#h#vw#vh#auto#fr1#fr3 - - - - - - - - - - - - ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····• + + + + + + + + + + + + + #cells#percent#w#h#vw#vh#auto#fr1#fr3 + + + + + + + + + + + + ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····• diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index bdedbced3..874f096d9 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -91,7 +91,6 @@ def test_buttons_render(snap_compare): def test_placeholder_render(snap_compare): # Testing the rendering of the multiple placeholder variants and labels. - Placeholder.reset_color_cycle() assert snap_compare(WIDGET_EXAMPLES_DIR / "placeholder.py") @@ -261,7 +260,6 @@ PATHS = [ @pytest.mark.parametrize("file_name", PATHS) def test_css_property(file_name, snap_compare): path_to_app = STYLES_EXAMPLES_DIR / file_name - Placeholder.reset_color_cycle() assert snap_compare(path_to_app) diff --git a/tests/test_footer.py b/tests/test_footer.py new file mode 100644 index 000000000..26e46cf29 --- /dev/null +++ b/tests/test_footer.py @@ -0,0 +1,28 @@ +from textual.app import App, ComposeResult +from textual.geometry import Offset +from textual.screen import ModalScreen +from textual.widgets import Footer, Label + + +async def test_footer_highlight_when_pushing_modal(): + """Regression test for https://github.com/Textualize/textual/issues/2606""" + + class MyModalScreen(ModalScreen): + def compose(self) -> ComposeResult: + yield Label("apple") + + class MyApp(App[None]): + BINDINGS = [("a", "p", "push")] + + def compose(self) -> ComposeResult: + yield Footer() + + def action_p(self): + self.push_screen(MyModalScreen()) + + app = MyApp() + async with app.run_test(size=(80, 2)) as pilot: + await pilot.hover(None, Offset(0, 1)) + await pilot.click(None, Offset(0, 1)) + assert isinstance(app.screen, MyModalScreen) + assert app.screen_stack[0].query_one(Footer).highlight_key is None diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py new file mode 100644 index 000000000..6fd5c185d --- /dev/null +++ b/tests/test_screen_modes.py @@ -0,0 +1,277 @@ +from functools import partial +from itertools import cycle +from typing import Type + +import pytest + +from textual.app import ( + ActiveModeError, + App, + ComposeResult, + InvalidModeError, + UnknownModeError, +) +from textual.screen import ModalScreen, Screen +from textual.widgets import Footer, Header, Label, TextLog + +FRUITS = cycle("apple mango strawberry banana peach pear melon watermelon".split()) + + +class ScreenBindingsMixin(Screen[None]): + BINDINGS = [ + ("1", "one", "Mode 1"), + ("2", "two", "Mode 2"), + ("p", "push", "Push rnd scrn"), + ("o", "pop_screen", "Pop"), + ("r", "remove", "Remove mode 1"), + ] + + def action_one(self) -> None: + self.app.switch_mode("one") + + def action_two(self) -> None: + self.app.switch_mode("two") + + def action_fruits(self) -> None: + self.app.switch_mode("fruits") + + def action_push(self) -> None: + self.app.push_screen(FruitModal()) + + +class BaseScreen(ScreenBindingsMixin): + def __init__(self, label): + super().__init__() + self.label = label + + def compose(self) -> ComposeResult: + yield Header() + yield Label(self.label) + yield Footer() + + def action_remove(self) -> None: + self.app.remove_mode("one") + + +class FruitModal(ModalScreen[str], ScreenBindingsMixin): + BINDINGS = [("d", "dismiss_fruit", "Dismiss")] + + def compose(self) -> ComposeResult: + yield Label(next(FRUITS)) + + +class FruitsScreen(ScreenBindingsMixin): + def compose(self) -> ComposeResult: + yield TextLog() + + +@pytest.fixture +def ModesApp(): + class ModesApp(App[None]): + MODES = { + "one": lambda: BaseScreen("one"), + "two": "screen_two", + } + + SCREENS = { + "screen_two": lambda: BaseScreen("two"), + } + + def on_mount(self): + self.switch_mode("one") + + return ModesApp + + +async def test_mode_setup(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test(): + assert isinstance(app.screen, BaseScreen) + assert str(app.screen.query_one(Label).renderable) == "one" + + +async def test_switch_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test() as pilot: + await pilot.press("2") + assert str(app.screen.query_one(Label).renderable) == "two" + await pilot.press("1") + assert str(app.screen.query_one(Label).renderable) == "one" + + +async def test_switch_same_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test() as pilot: + await pilot.press("1") + assert str(app.screen.query_one(Label).renderable) == "one" + await pilot.press("1") + assert str(app.screen.query_one(Label).renderable) == "one" + + +async def test_switch_unknown_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test(): + with pytest.raises(UnknownModeError): + app.switch_mode("unknown mode here") + + +async def test_remove_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test() as pilot: + app.switch_mode("two") + await pilot.pause() + assert str(app.screen.query_one(Label).renderable) == "two" + app.remove_mode("one") + assert "one" not in app.MODES + + +async def test_remove_active_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test(): + with pytest.raises(ActiveModeError): + app.remove_mode("one") + + +async def test_add_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test() as pilot: + app.add_mode("three", BaseScreen("three")) + app.switch_mode("three") + await pilot.pause() + assert str(app.screen.query_one(Label).renderable) == "three" + + +async def test_add_mode_duplicated(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test(): + with pytest.raises(InvalidModeError): + app.add_mode("one", BaseScreen("one")) + + +async def test_screen_stack_preserved(ModesApp: Type[App]): + fruits = [] + N = 5 + + app = ModesApp() + async with app.run_test() as pilot: + # Build the stack up. + for _ in range(N): + await pilot.press("p") + fruits.append(str(app.query_one(Label).renderable)) + + assert len(app.screen_stack) == N + 1 + + # Switch out and back. + await pilot.press("2") + assert len(app.screen_stack) == 1 + await pilot.press("1") + + # Check the stack. + assert len(app.screen_stack) == N + 1 + for _ in range(N): + assert str(app.query_one(Label).renderable) == fruits.pop() + await pilot.press("o") + + +async def test_inactive_stack_is_alive(): + """This tests that timers in screens outside the active stack keep going.""" + pings = [] + + class FastCounter(Screen[None]): + def compose(self) -> ComposeResult: + yield Label("fast") + + def on_mount(self) -> None: + self.set_interval(0.01, self.ping) + + def ping(self) -> None: + pings.append(str(self.app.query_one(Label).renderable)) + + def key_s(self): + self.app.switch_mode("smile") + + class SmileScreen(Screen[None]): + def compose(self) -> ComposeResult: + yield Label(":)") + + def key_s(self): + self.app.switch_mode("fast") + + class ModesApp(App[None]): + MODES = { + "fast": FastCounter, + "smile": SmileScreen, + } + + def on_mount(self) -> None: + self.switch_mode("fast") + + app = ModesApp() + async with app.run_test() as pilot: + await pilot.press("s") + assert str(app.query_one(Label).renderable) == ":)" + await pilot.press("s") + assert ":)" in pings + + +async def test_multiple_mode_callbacks(): + written = [] + + class LogScreen(Screen[None]): + def __init__(self, value): + super().__init__() + self.value = value + + def key_p(self) -> None: + self.app.push_screen(ResultScreen(self.value), written.append) + + class ResultScreen(Screen[str]): + def __init__(self, value): + super().__init__() + self.value = value + + def key_p(self) -> None: + self.dismiss(self.value) + + def key_f(self) -> None: + self.app.switch_mode("first") + + def key_o(self) -> None: + self.app.switch_mode("other") + + class ModesApp(App[None]): + MODES = { + "first": lambda: LogScreen("first"), + "other": lambda: LogScreen("other"), + } + + def on_mount(self) -> None: + self.switch_mode("first") + + def key_f(self) -> None: + self.switch_mode("first") + + def key_o(self) -> None: + self.switch_mode("other") + + app = ModesApp() + async with app.run_test() as pilot: + # Push and dismiss ResultScreen("first") + await pilot.press("p") + await pilot.press("p") + assert written == ["first"] + + # Push ResultScreen("first") + await pilot.press("p") + # Switch to LogScreen("other") + await pilot.press("o") + # Push and dismiss ResultScreen("other") + await pilot.press("p") + await pilot.press("p") + assert written == ["first", "other"] + + # Go back to ResultScreen("first") + await pilot.press("f") + # Dismiss ResultScreen("first") + await pilot.press("p") + assert written == ["first", "other", "first"]