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