From 9f3f2033b576f0e318d1564b98780586de6e0060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 10 May 2023 14:59:00 +0100 Subject: [PATCH 01/19] Add default mode. --- src/textual/app.py | 60 ++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 12f66cf04..b0e0b0811 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -159,6 +159,10 @@ class ScreenStackError(ScreenError): """Raised when attempting to pop the last screen from the stack.""" +class UnknownModeError(Exception): + """Raised when attempting to use a mode that is not known.""" + + class CssPathError(Exception): """Raised when supplied CSS path(s) are invalid.""" @@ -294,7 +298,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 +533,7 @@ 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() def exit( self, result: ReturnType | None = None, message: RenderableType | None = None @@ -680,7 +687,9 @@ class App(Generic[ReturnType], DOMNode): ScreenStackError: If there are no screens on the stack. """ try: - return self._screen_stack[-1] + return self._screen_stacks[self._current_mode][-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 @@ -688,7 +697,7 @@ class App(Generic[ReturnType], DOMNode): def _background_screens(self) -> list[Screen]: """A list of screens that may be visible due to background opacity (top-most first, not including current screen).""" screens: list[Screen] = [] - for screen in reversed(self._screen_stack[:-1]): + for screen in reversed(self._screen_stacks[self._current_mode][:-1]): screens.append(screen) if screen.styles.background.a == 1: break @@ -1398,11 +1407,14 @@ class App(Generic[ReturnType], DOMNode): Returns: The screen that was replaced. """ - if self._screen_stack: + if self._screen_stacks[self._current_mode]: 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 screen not in self._screen_stacks[self._current_mode] + ): screen.remove() self.log.system(f"{screen} REMOVED") return screen @@ -1426,14 +1438,14 @@ class App(Generic[ReturnType], DOMNode): f"push_screen requires a Screen instance or str; not {screen!r}" ) - if self._screen_stack: + if self._screen_stacks[self._current_mode]: self.screen.post_message(events.ScreenSuspend()) self.screen.refresh() next_screen, await_mount = self._get_screen(screen) next_screen._push_result_callback( - self.screen if self._screen_stack else None, callback + self.screen if self._screen_stacks[self._current_mode] else None, callback ) - self._screen_stack.append(next_screen) + self._screen_stacks[self._current_mode].append(next_screen) next_screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (PUSHED)") return await_mount @@ -1449,10 +1461,12 @@ class App(Generic[ReturnType], DOMNode): f"switch_screen requires a Screen instance or str; not {screen!r}" ) if self.screen is not screen: - previous_screen = self._replace_screen(self._screen_stack.pop()) + previous_screen = self._replace_screen( + self._screen_stacks[self._current_mode].pop() + ) previous_screen._pop_result_callback() next_screen, await_mount = self._get_screen(screen) - self._screen_stack.append(next_screen) + self._screen_stacks[self._current_mode].append(next_screen) self.screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (SWITCHED)") return await_mount @@ -1503,13 +1517,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: @@ -1524,7 +1538,7 @@ class App(Generic[ReturnType], DOMNode): Returns: The screen that was replaced. """ - screen_stack = self._screen_stack + screen_stack = self._screen_stacks[self._current_mode] if len(screen_stack) <= 1: raise ScreenStackError( "Can't pop screen; there must be at least one screen on the stack" @@ -1940,12 +1954,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(): @@ -1990,7 +2004,7 @@ class App(Generic[ReturnType], DOMNode): await self._message_queue.put(None) def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: - if self._screen_stack: + if self._screen_stacks[self._current_mode]: self.screen.refresh(repaint=repaint, layout=layout) self.check_idle() @@ -2130,9 +2144,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.Compose): - screen = Screen(id="_default") + screen = Screen(id=f"_default_{self._current_mode}") self._register(self, screen) - self._screen_stack.append(screen) + self._screen_stacks[self._current_mode].append(screen) await super().on_event(event) elif isinstance(event, events.InputEvent) and not event.is_forwarded: From 6e19772563d61caa5ef93ec156613e1a10b0e192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 10 May 2023 16:06:16 +0100 Subject: [PATCH 02/19] Add ability to switch between modes. --- src/textual/app.py | 71 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b0e0b0811..0c83ee541 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -159,10 +159,18 @@ class ScreenStackError(ScreenError): """Raised when attempting to pop the last screen from the stack.""" -class UnknownModeError(Exception): +class ModeError(Exception): + """Base class for exceptions related to modes.""" + + +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.""" @@ -216,6 +224,8 @@ class App(Generic[ReturnType], DOMNode): } """ + MODES: ClassVar[set[str]] = set() + """Modes associated with the app for the lifetime of the app.""" SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {} """Screens associated with the app for the lifetime of the app.""" _BASE_PATH: str | None = None @@ -1335,6 +1345,63 @@ 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.""" + + screen = Screen(id=f"_default_{mode}") + self._register(self, 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.screen} is active") + + def add_mode(self, mode: str) -> None: + """Adds a mode to the app. + + Args: + mode: The new mode. + """ + self.MODES.add(mode) + + def remove_mode(self, mode: str) -> None: + """Removes a mode from the app. + + 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}") + + stack = self._screen_stacks.get(mode, []) + while stack: + self._replace_screen(stack.pop()) + self.MODES.remove(mode) + def is_screen_installed(self, screen: Screen | str) -> bool: """Check if a given screen has been installed. @@ -2144,7 +2211,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=f"_default_{self._current_mode}") + screen = Screen(id=f"_default") self._register(self, screen) self._screen_stacks[self._current_mode].append(screen) await super().on_event(event) From 4d287837a25f09b0013933e92e4b1697279ac4e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 11 May 2023 16:42:49 +0100 Subject: [PATCH 03/19] Refactor screen stack modes. --- src/textual/app.py | 79 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 0c83ee541..8d162ef19 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -163,6 +163,10 @@ 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.""" @@ -224,8 +228,35 @@ class App(Generic[ReturnType], DOMNode): } """ - MODES: ClassVar[set[str]] = set() - """Modes associated with the app for the lifetime of the app.""" + 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 @@ -1348,8 +1379,14 @@ class App(Generic[ReturnType], DOMNode): def _init_mode(self, mode: str) -> None: """Do internal initialisation of a new screen stack mode.""" - screen = Screen(id=f"_default_{mode}") - self._register(self, screen) + 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: @@ -1372,19 +1409,33 @@ class App(Generic[ReturnType], DOMNode): 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) -> None: - """Adds a mode to the app. + 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. """ - self.MODES.add(mode) + 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. @@ -1396,11 +1447,17 @@ class App(Generic[ReturnType], DOMNode): 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] - stack = self._screen_stacks.get(mode, []) - while stack: - self._replace_screen(stack.pop()) - self.MODES.remove(mode) + if mode not in self._screen_stacks: + return + + stack = self._screen_stacks[mode] + for screen in reversed(stack): + if screen._running: + self.call_later(self._prune_node, screen) + del self._screen_stacks[mode] def is_screen_installed(self, screen: Screen | str) -> bool: """Check if a given screen has been installed. From 634789ae938d2eab4897832997b5e15d50724fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 16 May 2023 17:40:09 +0100 Subject: [PATCH 04/19] Add tests to screen modes. --- tests/test_screen_modes.py | 276 +++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 tests/test_screen_modes.py diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py new file mode 100644 index 000000000..c345b2903 --- /dev/null +++ b/tests/test_screen_modes.py @@ -0,0 +1,276 @@ +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(): + class FastCounter(Screen[None]): + def compose(self) -> ComposeResult: + self.lbl = Label("0") + yield self.lbl + + def on_mount(self) -> None: + self.set_interval(0.01, self.increment) + + def increment(self) -> None: + self.lbl.update(str(int(str(self.lbl.renderable)) + 1)) + + 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: + current = int(str(app.query_one(Label).renderable)) + await pilot.press("s") + assert str(app.query_one(Label).renderable) == ":)" + await pilot.press("s") + assert int(str(app.query_one(Label).renderable)) > current + + +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"] From a058fe53eb76bb64ae16375b696828f53db11f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 17 May 2023 11:15:56 +0100 Subject: [PATCH 05/19] Make test clearer. --- tests/test_screen_modes.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py index c345b2903..6fd5c185d 100644 --- a/tests/test_screen_modes.py +++ b/tests/test_screen_modes.py @@ -174,16 +174,18 @@ async def test_screen_stack_preserved(ModesApp: Type[App]): 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: - self.lbl = Label("0") - yield self.lbl + yield Label("fast") def on_mount(self) -> None: - self.set_interval(0.01, self.increment) + self.set_interval(0.01, self.ping) - def increment(self) -> None: - self.lbl.update(str(int(str(self.lbl.renderable)) + 1)) + def ping(self) -> None: + pings.append(str(self.app.query_one(Label).renderable)) def key_s(self): self.app.switch_mode("smile") @@ -206,11 +208,10 @@ async def test_inactive_stack_is_alive(): app = ModesApp() async with app.run_test() as pilot: - current = int(str(app.query_one(Label).renderable)) await pilot.press("s") assert str(app.query_one(Label).renderable) == ":)" await pilot.press("s") - assert int(str(app.query_one(Label).renderable)) > current + assert ":)" in pings async def test_multiple_mode_callbacks(): From d65daf81c04350712ad5fc737c1103bd6f849717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 15:07:52 +0100 Subject: [PATCH 06/19] Address review comments. --- src/textual/app.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 8d162ef19..81ddee40b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1454,10 +1454,9 @@ class App(Generic[ReturnType], DOMNode): return stack = self._screen_stacks[mode] - for screen in reversed(stack): - if screen._running: - self.call_later(self._prune_node, screen) 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. @@ -1535,9 +1534,8 @@ 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_stacks[self._current_mode] + 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") From c85e428228be8f82a93814bc48457a4f456d90f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 16:24:07 +0100 Subject: [PATCH 07/19] Fix placeholder color cycling. --- CHANGELOG.md | 5 +++++ src/textual/widgets/_placeholder.py | 20 ++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42bf44a47..98a555d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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 + +### Changed + +- `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590 ## [0.25.0] - 2023-05-17 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. From 6523fbaff1a1e239691b20f196f0e4f18afeae9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 16:26:24 +0100 Subject: [PATCH 08/19] Fix tests. --- CHANGELOG.md | 4 + docs/examples/styles/width_comparison.py | 8 +- .../__snapshots__/test_snapshots.ambr | 128 +++++++++--------- tests/snapshot_tests/test_snapshots.py | 2 - 4 files changed, 73 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a555d10..418606fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590 +### Removed + +- `Placeholder.reset_color_cycle` + ## [0.25.0] - 2023-05-17 ### Changed 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/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) From 7dd05e3ec0fd617d4d373cb0626b2f6838d49ca2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 19 May 2023 10:15:04 +0100 Subject: [PATCH 09/19] Let child classes of DirectoryTree override Path creation With #1719 in mind, and as an alternative to #2608, this allows for a child class of DirectoryTree to specify how a fresh `Path` should be created. The idea here being that whatever is created should be of the `Path` type, but can have other abilities. --- src/textual/widgets/_directory_tree.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 72a19ddf1..6081a240a 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. From 33da5c1afca1e13e3bd6206d249093db828bb801 Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Mon, 22 May 2023 11:27:31 +0200 Subject: [PATCH 10/19] Fix App.BINDINGS type (#2620) The implicit type was creating mypy errors when defining bindings with tuples. For example: class MyApp(App): BINDINGS = [("q", "quit", "Quit")] Would give the error: error: List item 0 has incompatible type "Tuple[str, str, str]"; expected "Binding" [list-item] --- src/textual/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index d92c858dc..05ae29922 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, _Bindings, BindingType from .css.query import NoMatches from .css.stylesheet import Stylesheet from .design import ColorSystem @@ -230,7 +230,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) From 5e04a4d4de3eceaeecf4a45bcfa033d1a1f02dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 22 May 2023 10:32:23 +0100 Subject: [PATCH 11/19] Add description to work decorator. (#2605) * Add description to work decorator. * Fix stutter. --- CHANGELOG.md | 7 +++++++ src/textual/_work_decorator.py | 27 +++++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb93b504..64b1df49a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Textual will now scroll focused widgets to center if not in view +## Unreleased + +### Added + +- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597 + + ## [0.25.0] - 2023-05-17 ### Changed 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, ), From 33a470f56965bed37104fb2e609bf32ca3948610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 22 May 2023 11:45:40 +0100 Subject: [PATCH 12/19] Fix footer highlight when pushing modal. --- CHANGELOG.md | 1 + src/textual/widgets/_footer.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba7ec153..d838c46f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### 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 ### Removed 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__() From c32d5d3c25321bb3d1b7930ec1ac96c2046a14e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 22 May 2023 13:53:58 +0100 Subject: [PATCH 13/19] Add regression test for #2606. --- tests/test_footer.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_footer.py 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 From c64111bcb585b8c40eabf376be130fdeca984cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 22 May 2023 14:21:11 +0100 Subject: [PATCH 14/19] Add property alias. Related comment: https://github.com/Textualize/textual/pull/2540\#discussion_r1196634789 --- src/textual/app.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 5ba8089ac..6a44941f9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -592,6 +592,18 @@ class App(Generic[ReturnType], DOMNode): """ 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 ) -> None: @@ -737,7 +749,7 @@ class App(Generic[ReturnType], DOMNode): ScreenStackError: If there are no screens on the stack. """ try: - return self._screen_stacks[self._current_mode][-1] + return self._screen_stack[-1] except KeyError: raise UnknownModeError(f"No known mode {self._current_mode!r}") from None except IndexError: @@ -747,7 +759,7 @@ class App(Generic[ReturnType], DOMNode): def _background_screens(self) -> list[Screen]: """A list of screens that may be visible due to background opacity (top-most first, not including current screen).""" screens: list[Screen] = [] - for screen in reversed(self._screen_stacks[self._current_mode][:-1]): + for screen in reversed(self._screen_stack[:-1]): screens.append(screen) if screen.styles.background.a == 1: break @@ -1539,7 +1551,7 @@ class App(Generic[ReturnType], DOMNode): Returns: The screen that was replaced. """ - if self._screen_stacks[self._current_mode]: + if self._screen_stack: self.screen.refresh() screen.post_message(events.ScreenSuspend()) self.log.system(f"{screen} SUSPENDED") @@ -1569,14 +1581,14 @@ class App(Generic[ReturnType], DOMNode): f"push_screen requires a Screen instance or str; not {screen!r}" ) - if self._screen_stacks[self._current_mode]: + if self._screen_stack: self.screen.post_message(events.ScreenSuspend()) self.screen.refresh() next_screen, await_mount = self._get_screen(screen) next_screen._push_result_callback( - self.screen if self._screen_stacks[self._current_mode] else None, callback + self.screen if self._screen_stack else None, callback ) - self._screen_stacks[self._current_mode].append(next_screen) + self._screen_stack.append(next_screen) next_screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (PUSHED)") return await_mount @@ -1592,12 +1604,10 @@ class App(Generic[ReturnType], DOMNode): f"switch_screen requires a Screen instance or str; not {screen!r}" ) if self.screen is not screen: - previous_screen = self._replace_screen( - self._screen_stacks[self._current_mode].pop() - ) + previous_screen = self._replace_screen(self._screen_stack.pop()) previous_screen._pop_result_callback() next_screen, await_mount = self._get_screen(screen) - self._screen_stacks[self._current_mode].append(next_screen) + self._screen_stack.append(next_screen) self.screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (SWITCHED)") return await_mount @@ -1669,7 +1679,7 @@ class App(Generic[ReturnType], DOMNode): Returns: The screen that was replaced. """ - screen_stack = self._screen_stacks[self._current_mode] + 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" @@ -2149,7 +2159,7 @@ class App(Generic[ReturnType], DOMNode): await self._message_queue.put(None) def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: - if self._screen_stacks[self._current_mode]: + if self._screen_stack: self.screen.refresh(repaint=repaint, layout=layout) self.check_idle() @@ -2291,7 +2301,7 @@ class App(Generic[ReturnType], DOMNode): if isinstance(event, events.Compose): screen = Screen(id=f"_default") self._register(self, screen) - self._screen_stacks[self._current_mode].append(screen) + self._screen_stack.append(screen) screen.post_message(events.ScreenResume()) await super().on_event(event) From be49aabefec472968043fa7e2f73e5ad30a48ca5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 22 May 2023 20:26:37 +0100 Subject: [PATCH 15/19] remove markup, simplify repr (#2623) * remove markup, simplify repr * changelog * remove rendundant repr (thanks Paul) * changelog --- CHANGELOG.md | 1 + src/textual/app.py | 6 +++--- src/textual/events.py | 3 --- src/textual/widget.py | 5 ----- 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d838c46f5..3085106dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `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 diff --git a/src/textual/app.py b/src/textual/app.py index 7e02572d4..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, BindingType +from .binding import Binding, BindingType, _Bindings from .css.query import NoMatches from .css.stylesheet import Stylesheet from .design import ColorSystem @@ -1853,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.""" @@ -2713,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. From 10bccfd9ee96e27ee34642a0248689a3af749939 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 09:27:40 +0100 Subject: [PATCH 16/19] Fix a copy/pasteo in an option list docstring --- src/textual/widgets/_option_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 7d635915faf8f39c07928d96a8692749e5258fb3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 10:31:34 +0100 Subject: [PATCH 17/19] Fix Select reactives table layout The escaped | wasn't being rendered correctly as it was inside back-ticks. --- docs/widgets/select.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From 7f3efcf6ed0bf19aaf5cfa3b35be227424e486a3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 10:41:55 +0100 Subject: [PATCH 18/19] Fix a typo in the OptionList messages list --- docs/widgets/option_list.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/option_list.md b/docs/widgets/option_list.md index a489e6b4f..7d75a8833 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. From 7c9b3f4cd60ea446b0ab8b3ec446ccb60a027763 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:13:17 +0100 Subject: [PATCH 19/19] Include widgets.option_list.Option in the docs Noticed this in passing; possibly dropped by accident when the widgets were removed form the API section of the docs? --- docs/widgets/option_list.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/widgets/option_list.md b/docs/widgets/option_list.md index a489e6b4f..8192b7672 100644 --- a/docs/widgets/option_list.md +++ b/docs/widgets/option_list.md @@ -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