mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into multiselect
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590
|
||||||
|
- Footer now clears key highlight regardless of whether it's in the active screen or not https://github.com/Textualize/textual/issues/2606
|
||||||
|
- The default Widget repr no longer displays classes and pseudo-classes (to reduce noise in logs). Add them to your `__rich_repr__` method if needed. https://github.com/Textualize/textual/pull/2623
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `Placeholder.reset_color_cycle`
|
||||||
|
|
||||||
|
|
||||||
## [0.26.0] - 2023-05-20
|
## [0.26.0] - 2023-05-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from textual.app import App
|
from textual.app import App
|
||||||
from textual.containers import Horizontal
|
from textual.containers import Horizontal
|
||||||
from textual.widgets import Placeholder, Label, Static
|
from textual.widgets import Label, Placeholder, Static
|
||||||
|
|
||||||
|
|
||||||
class Ruler(Static):
|
class Ruler(Static):
|
||||||
@@ -9,7 +9,7 @@ class Ruler(Static):
|
|||||||
yield Label(ruler_text)
|
yield Label(ruler_text)
|
||||||
|
|
||||||
|
|
||||||
class HeightComparisonApp(App):
|
class WidthComparisonApp(App):
|
||||||
def compose(self):
|
def compose(self):
|
||||||
yield Horizontal(
|
yield Horizontal(
|
||||||
Placeholder(id="cells"), # (1)!
|
Placeholder(id="cells"), # (1)!
|
||||||
@@ -25,4 +25,6 @@ class HeightComparisonApp(App):
|
|||||||
yield Ruler()
|
yield Ruler()
|
||||||
|
|
||||||
|
|
||||||
app = HeightComparisonApp(css_path="width_comparison.css")
|
app = WidthComparisonApp(css_path="width_comparison.css")
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ tables](https://rich.readthedocs.io/en/latest/tables.html):
|
|||||||
|
|
||||||
## Messages
|
## Messages
|
||||||
|
|
||||||
- [OptionList.OptionHighlight][textual.widgets.OptionList.OptionHighlighted]
|
- [OptionList.OptionHighlighted][textual.widgets.OptionList.OptionHighlighted]
|
||||||
- [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected]
|
- [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.
|
Both of the messages above inherit from the common base [`OptionList`][textual.widgets.OptionList.OptionMessage], so refer to its documentation to see what attributes are available.
|
||||||
@@ -115,3 +115,8 @@ The option list provides the following component classes:
|
|||||||
::: textual.widgets.OptionList
|
::: textual.widgets.OptionList
|
||||||
options:
|
options:
|
||||||
heading_level: 2
|
heading_level: 2
|
||||||
|
|
||||||
|
|
||||||
|
::: textual.widgets.option_list.Option
|
||||||
|
options:
|
||||||
|
heading_level: 2
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ The following example presents a `Select` with a number of options.
|
|||||||
|
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
| ---------- | -------------------- | ------- | ----------------------------------- |
|
|------------|------------------------|---------|-------------------------------------|
|
||||||
| `expanded` | `bool` | `False` | True to expand the options overlay. |
|
| `expanded` | `bool` | `False` | True to expand the options overlay. |
|
||||||
| `value` | `SelectType \| None` | `None` | Current value of the Select. |
|
| `value` | `SelectType` \| `None` | `None` | Current value of the Select. |
|
||||||
|
|
||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def work(
|
|||||||
group: str = "default",
|
group: str = "default",
|
||||||
exit_on_error: bool = True,
|
exit_on_error: bool = True,
|
||||||
exclusive: bool = False,
|
exclusive: bool = False,
|
||||||
|
description: str | None = None,
|
||||||
) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator:
|
) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator:
|
||||||
"""A decorator used to create [workers](/guide/workers).
|
"""A decorator used to create [workers](/guide/workers).
|
||||||
|
|
||||||
@@ -67,6 +68,9 @@ def work(
|
|||||||
group: A short string to identify a group of workers.
|
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.
|
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.
|
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(
|
def decorator(
|
||||||
@@ -87,22 +91,25 @@ def work(
|
|||||||
self = args[0]
|
self = args[0]
|
||||||
assert isinstance(self, DOMNode)
|
assert isinstance(self, DOMNode)
|
||||||
|
|
||||||
|
if description is not None:
|
||||||
|
debug_description = description
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
positional_arguments = ", ".join(repr(arg) for arg in args[1:])
|
positional_arguments = ", ".join(repr(arg) for arg in args[1:])
|
||||||
keyword_arguments = ", ".join(
|
keyword_arguments = ", ".join(
|
||||||
f"{name}={value!r}" for name, value in kwargs.items()
|
f"{name}={value!r}" for name, value in kwargs.items()
|
||||||
)
|
)
|
||||||
tokens = [positional_arguments, keyword_arguments]
|
tokens = [positional_arguments, keyword_arguments]
|
||||||
worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})"
|
debug_description = f"{method.__name__}({', '.join(token for token in tokens if token)})"
|
||||||
except Exception:
|
except Exception:
|
||||||
worker_description = "<worker>"
|
debug_description = "<worker>"
|
||||||
worker = cast(
|
worker = cast(
|
||||||
"Worker[ReturnType]",
|
"Worker[ReturnType]",
|
||||||
self.run_worker(
|
self.run_worker(
|
||||||
partial(method, *args, **kwargs),
|
partial(method, *args, **kwargs),
|
||||||
name=name or method.__name__,
|
name=name or method.__name__,
|
||||||
group=group,
|
group=group,
|
||||||
description=worker_description,
|
description=debug_description,
|
||||||
exclusive=exclusive,
|
exclusive=exclusive,
|
||||||
exit_on_error=exit_on_error,
|
exit_on_error=exit_on_error,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ from ._wait import wait_for_idle
|
|||||||
from ._worker_manager import WorkerManager
|
from ._worker_manager import WorkerManager
|
||||||
from .actions import ActionParseResult, SkipAction
|
from .actions import ActionParseResult, SkipAction
|
||||||
from .await_remove import AwaitRemove
|
from .await_remove import AwaitRemove
|
||||||
from .binding import Binding, _Bindings
|
from .binding import Binding, BindingType, _Bindings
|
||||||
from .css.query import NoMatches
|
from .css.query import NoMatches
|
||||||
from .css.stylesheet import Stylesheet
|
from .css.stylesheet import Stylesheet
|
||||||
from .design import ColorSystem
|
from .design import ColorSystem
|
||||||
@@ -159,6 +159,38 @@ class ScreenStackError(ScreenError):
|
|||||||
"""Raised when trying to manipulate the screen stack incorrectly."""
|
"""Raised when trying to manipulate the screen stack incorrectly."""
|
||||||
|
|
||||||
|
|
||||||
|
class ModeError(Exception):
|
||||||
|
"""Base class for exceptions related to modes."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidModeError(ModeError):
|
||||||
|
"""Raised if there is an issue with a mode name."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownModeError(ModeError):
|
||||||
|
"""Raised when attempting to use a mode that is not known."""
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveModeError(ModeError):
|
||||||
|
"""Raised when attempting to remove the currently active mode."""
|
||||||
|
|
||||||
|
|
||||||
|
class ModeError(Exception):
|
||||||
|
"""Base class for exceptions related to modes."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidModeError(ModeError):
|
||||||
|
"""Raised if there is an issue with a mode name."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownModeError(ModeError):
|
||||||
|
"""Raised when attempting to use a mode that is not known."""
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveModeError(ModeError):
|
||||||
|
"""Raised when attempting to remove the currently active mode."""
|
||||||
|
|
||||||
|
|
||||||
class CssPathError(Exception):
|
class CssPathError(Exception):
|
||||||
"""Raised when supplied CSS path(s) are invalid."""
|
"""Raised when supplied CSS path(s) are invalid."""
|
||||||
|
|
||||||
@@ -212,6 +244,35 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
MODES: ClassVar[dict[str, str | Screen | Callable[[], Screen]]] = {}
|
||||||
|
"""Modes associated with the app and their base screens.
|
||||||
|
|
||||||
|
The base screen is the screen at the bottom of the mode stack. You can think of
|
||||||
|
it as the default screen for that stack.
|
||||||
|
The base screens can be names of screens listed in [SCREENS][textual.app.App.SCREENS],
|
||||||
|
[`Screen`][textual.screen.Screen] instances, or callables that return screens.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```py
|
||||||
|
class HelpScreen(Screen[None]):
|
||||||
|
...
|
||||||
|
|
||||||
|
class MainAppScreen(Screen[None]):
|
||||||
|
...
|
||||||
|
|
||||||
|
class MyApp(App[None]):
|
||||||
|
MODES = {
|
||||||
|
"default": "main",
|
||||||
|
"help": HelpScreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
SCREENS = {
|
||||||
|
"main": MainAppScreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
"""
|
||||||
SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {}
|
SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {}
|
||||||
"""Screens associated with the app for the lifetime of the app."""
|
"""Screens associated with the app for the lifetime of the app."""
|
||||||
_BASE_PATH: str | None = None
|
_BASE_PATH: str | None = None
|
||||||
@@ -230,7 +291,9 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
To update the sub-title while the app is running, you can set the [sub_title][textual.app.App.sub_title] attribute.
|
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)
|
title: Reactive[str] = Reactive("", compute=False)
|
||||||
sub_title: Reactive[str] = Reactive("", compute=False)
|
sub_title: Reactive[str] = Reactive("", compute=False)
|
||||||
@@ -294,7 +357,10 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self._workers = WorkerManager(self)
|
self._workers = WorkerManager(self)
|
||||||
self.error_console = Console(markup=False, stderr=True)
|
self.error_console = Console(markup=False, stderr=True)
|
||||||
self.driver_class = driver_class or self.get_driver_class()
|
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._sync_available = False
|
||||||
|
|
||||||
self.mouse_over: Widget | None = None
|
self.mouse_over: Widget | None = None
|
||||||
@@ -526,7 +592,19 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
Returns:
|
Returns:
|
||||||
A snapshot of the current state of the screen stack.
|
A snapshot of the current state of the screen stack.
|
||||||
"""
|
"""
|
||||||
return self._screen_stack.copy()
|
return self._screen_stacks[self._current_mode].copy()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _screen_stack(self) -> list[Screen]:
|
||||||
|
"""A reference to the current screen stack.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Consider using [`screen_stack`][textual.app.App.screen_stack] instead.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A reference to the current screen stack.
|
||||||
|
"""
|
||||||
|
return self._screen_stacks[self._current_mode]
|
||||||
|
|
||||||
def exit(
|
def exit(
|
||||||
self, result: ReturnType | None = None, message: RenderableType | None = None
|
self, result: ReturnType | None = None, message: RenderableType | None = None
|
||||||
@@ -674,6 +752,8 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return self._screen_stack[-1]
|
return self._screen_stack[-1]
|
||||||
|
except KeyError:
|
||||||
|
raise UnknownModeError(f"No known mode {self._current_mode!r}") from None
|
||||||
except IndexError:
|
except IndexError:
|
||||||
raise ScreenStackError("No screens on stack") from None
|
raise ScreenStackError("No screens on stack") from None
|
||||||
|
|
||||||
@@ -1319,6 +1399,88 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
"""
|
"""
|
||||||
return self.mount(*widgets, before=before, after=after)
|
return self.mount(*widgets, before=before, after=after)
|
||||||
|
|
||||||
|
def _init_mode(self, mode: str) -> None:
|
||||||
|
"""Do internal initialisation of a new screen stack mode."""
|
||||||
|
|
||||||
|
stack = self._screen_stacks.get(mode, [])
|
||||||
|
if not stack:
|
||||||
|
_screen = self.MODES[mode]
|
||||||
|
if callable(_screen):
|
||||||
|
screen, _ = self._get_screen(_screen())
|
||||||
|
else:
|
||||||
|
screen, _ = self._get_screen(self.MODES[mode])
|
||||||
|
stack.append(screen)
|
||||||
|
self._screen_stacks[mode] = [screen]
|
||||||
|
|
||||||
|
def switch_mode(self, mode: str) -> None:
|
||||||
|
"""Switch to a given mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: The mode to switch to.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnknownModeError: If trying to switch to an unknown mode.
|
||||||
|
"""
|
||||||
|
if mode not in self.MODES:
|
||||||
|
raise UnknownModeError(f"No known mode {mode!r}")
|
||||||
|
|
||||||
|
self.screen.post_message(events.ScreenSuspend())
|
||||||
|
self.screen.refresh()
|
||||||
|
|
||||||
|
if mode not in self._screen_stacks:
|
||||||
|
self._init_mode(mode)
|
||||||
|
self._current_mode = mode
|
||||||
|
self.screen._screen_resized(self.size)
|
||||||
|
self.screen.post_message(events.ScreenResume())
|
||||||
|
self.log.system(f"{self._current_mode!r} is the current mode")
|
||||||
|
self.log.system(f"{self.screen} is active")
|
||||||
|
|
||||||
|
def add_mode(
|
||||||
|
self, mode: str, base_screen: str | Screen | Callable[[], Screen]
|
||||||
|
) -> None:
|
||||||
|
"""Adds a mode and its corresponding base screen to the app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: The new mode.
|
||||||
|
base_screen: The base screen associated with the given mode.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidModeError: If the name of the mode is not valid/duplicated.
|
||||||
|
"""
|
||||||
|
if mode == "_default":
|
||||||
|
raise InvalidModeError("Cannot use '_default' as a custom mode.")
|
||||||
|
elif mode in self.MODES:
|
||||||
|
raise InvalidModeError(f"Duplicated mode name {mode!r}.")
|
||||||
|
|
||||||
|
self.MODES[mode] = base_screen
|
||||||
|
|
||||||
|
def remove_mode(self, mode: str) -> None:
|
||||||
|
"""Removes a mode from the app.
|
||||||
|
|
||||||
|
Screens that are running in the stack of that mode are scheduled for pruning.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: The mode to remove. It can't be the active mode.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ActiveModeError: If trying to remove the active mode.
|
||||||
|
UnknownModeError: If trying to remove an unknown mode.
|
||||||
|
"""
|
||||||
|
if mode == self._current_mode:
|
||||||
|
raise ActiveModeError(f"Can't remove active mode {mode!r}")
|
||||||
|
elif mode not in self.MODES:
|
||||||
|
raise UnknownModeError(f"Unknown mode {mode!r}")
|
||||||
|
else:
|
||||||
|
del self.MODES[mode]
|
||||||
|
|
||||||
|
if mode not in self._screen_stacks:
|
||||||
|
return
|
||||||
|
|
||||||
|
stack = self._screen_stacks[mode]
|
||||||
|
del self._screen_stacks[mode]
|
||||||
|
for screen in reversed(stack):
|
||||||
|
self._replace_screen(screen)
|
||||||
|
|
||||||
def is_screen_installed(self, screen: Screen | str) -> bool:
|
def is_screen_installed(self, screen: Screen | str) -> bool:
|
||||||
"""Check if a given screen has been installed.
|
"""Check if a given screen has been installed.
|
||||||
|
|
||||||
@@ -1395,7 +1557,9 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self.screen.refresh()
|
self.screen.refresh()
|
||||||
screen.post_message(events.ScreenSuspend())
|
screen.post_message(events.ScreenSuspend())
|
||||||
self.log.system(f"{screen} SUSPENDED")
|
self.log.system(f"{screen} SUSPENDED")
|
||||||
if not self.is_screen_installed(screen) and screen not in self._screen_stack:
|
if not self.is_screen_installed(screen) and all(
|
||||||
|
screen not in stack for stack in self._screen_stacks.values()
|
||||||
|
):
|
||||||
screen.remove()
|
screen.remove()
|
||||||
self.log.system(f"{screen} REMOVED")
|
self.log.system(f"{screen} REMOVED")
|
||||||
return screen
|
return screen
|
||||||
@@ -1496,13 +1660,13 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
if screen not in self._installed_screens:
|
if screen not in self._installed_screens:
|
||||||
return None
|
return None
|
||||||
uninstall_screen = self._installed_screens[screen]
|
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")
|
raise ScreenStackError("Can't uninstall screen in screen stack")
|
||||||
del self._installed_screens[screen]
|
del self._installed_screens[screen]
|
||||||
self.log.system(f"{uninstall_screen} UNINSTALLED name={screen!r}")
|
self.log.system(f"{uninstall_screen} UNINSTALLED name={screen!r}")
|
||||||
return screen
|
return screen
|
||||||
else:
|
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")
|
raise ScreenStackError("Can't uninstall screen in screen stack")
|
||||||
for name, installed_screen in self._installed_screens.items():
|
for name, installed_screen in self._installed_screens.items():
|
||||||
if installed_screen is screen:
|
if installed_screen is screen:
|
||||||
@@ -1689,7 +1853,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
if self.css_monitor:
|
if self.css_monitor:
|
||||||
self.set_interval(0.25, self.css_monitor, name="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():
|
async def run_process_messages():
|
||||||
"""The main message loop, invoke below."""
|
"""The main message loop, invoke below."""
|
||||||
@@ -1947,12 +2111,12 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
async def _close_all(self) -> None:
|
async def _close_all(self) -> None:
|
||||||
"""Close all message pumps."""
|
"""Close all message pumps."""
|
||||||
|
|
||||||
# Close all screens on the stack.
|
# Close all screens on all stacks:
|
||||||
for stack_screen in reversed(self._screen_stack):
|
for stack in self._screen_stacks.values():
|
||||||
|
for stack_screen in reversed(stack):
|
||||||
if stack_screen._running:
|
if stack_screen._running:
|
||||||
await self._prune_node(stack_screen)
|
await self._prune_node(stack_screen)
|
||||||
|
stack.clear()
|
||||||
self._screen_stack.clear()
|
|
||||||
|
|
||||||
# Close pre-defined screens.
|
# Close pre-defined screens.
|
||||||
for screen in self.SCREENS.values():
|
for screen in self.SCREENS.values():
|
||||||
@@ -2137,7 +2301,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
# Handle input events that haven't been forwarded
|
# Handle input events that haven't been forwarded
|
||||||
# If the event has been forwarded it may have bubbled up back to the App
|
# If the event has been forwarded it may have bubbled up back to the App
|
||||||
if isinstance(event, events.Compose):
|
if isinstance(event, events.Compose):
|
||||||
screen = Screen(id="_default")
|
screen = Screen(id=f"_default")
|
||||||
self._register(self, screen)
|
self._register(self, screen)
|
||||||
self._screen_stack.append(screen)
|
self._screen_stack.append(screen)
|
||||||
screen.post_message(events.ScreenResume())
|
screen.post_message(events.ScreenResume())
|
||||||
@@ -2549,7 +2713,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
def _on_terminal_supports_synchronized_output(
|
def _on_terminal_supports_synchronized_output(
|
||||||
self, message: messages.TerminalSupportsSynchronizedOutput
|
self, message: messages.TerminalSupportsSynchronizedOutput
|
||||||
) -> None:
|
) -> None:
|
||||||
log.system("[b green]SynchronizedOutput mode is supported")
|
log.system("SynchronizedOutput mode is supported")
|
||||||
self._sync_available = True
|
self._sync_available = True
|
||||||
|
|
||||||
def _begin_update(self) -> None:
|
def _begin_update(self) -> None:
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ if TYPE_CHECKING:
|
|||||||
class Event(Message):
|
class Event(Message):
|
||||||
"""The base class for all events."""
|
"""The base class for all events."""
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
|
||||||
yield from ()
|
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Callback(Event, bubble=False, verbose=True):
|
class Callback(Event, bubble=False, verbose=True):
|
||||||
|
|||||||
@@ -2532,11 +2532,6 @@ class Widget(DOMNode):
|
|||||||
yield "id", self.id, None
|
yield "id", self.id, None
|
||||||
if self.name:
|
if self.name:
|
||||||
yield "name", 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:
|
def _get_scrollable_region(self, region: Region) -> Region:
|
||||||
"""Adjusts the Widget region to accommodate scrollbars.
|
"""Adjusts the Widget region to accommodate scrollbars.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, Iterable, Iterator
|
from typing import Callable, ClassVar, Iterable, Iterator
|
||||||
|
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
from rich.text import Text, TextType
|
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):
|
class FileSelected(Message, bubble=True):
|
||||||
"""Posted when a file is selected.
|
"""Posted when a file is selected.
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
"""
|
"""
|
||||||
return self.tree
|
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.
|
"""The path that is the root of the directory tree.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
@@ -121,7 +124,7 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
self._load_queue: Queue[TreeNode[DirEntry]] = Queue()
|
self._load_queue: Queue[TreeNode[DirEntry]] = Queue()
|
||||||
super().__init__(
|
super().__init__(
|
||||||
str(path),
|
str(path),
|
||||||
data=DirEntry(Path(path)),
|
data=DirEntry(self.PATH(path)),
|
||||||
name=name,
|
name=name,
|
||||||
id=id,
|
id=id,
|
||||||
classes=classes,
|
classes=classes,
|
||||||
@@ -141,7 +144,7 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
|
|
||||||
def reload(self) -> None:
|
def reload(self) -> None:
|
||||||
"""Reload the `DirectoryTree` contents."""
|
"""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...
|
# Orphan the old queue...
|
||||||
self._load_queue = Queue()
|
self._load_queue = Queue()
|
||||||
# ...and replace the old load with a new one.
|
# ...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 result will always be a Python `Path` object, regardless of
|
||||||
the value given.
|
the value given.
|
||||||
"""
|
"""
|
||||||
return Path(path)
|
return self.PATH(path)
|
||||||
|
|
||||||
def watch_path(self) -> None:
|
def watch_path(self) -> None:
|
||||||
"""Watch for changes to the `path` of the directory tree.
|
"""Watch for changes to the `path` of the directory tree.
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ class Footer(Widget):
|
|||||||
|
|
||||||
def _on_leave(self, _: events.Leave) -> None:
|
def _on_leave(self, _: events.Leave) -> None:
|
||||||
"""Clear any highlight when the mouse leaves the widget"""
|
"""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:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
|
|||||||
@@ -753,7 +753,7 @@ class OptionList(ScrollView, can_focus=True):
|
|||||||
"""Get the option with the given ID.
|
"""Get the option with the given ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index: The ID of the option to get.
|
option_id: The ID of the option to get.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The option at with the ID.
|
The option at with the ID.
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
|
from typing import Iterator
|
||||||
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
from rich.console import RenderableType
|
from rich.console import RenderableType
|
||||||
from typing_extensions import Literal, Self
|
from typing_extensions import Literal, Self
|
||||||
|
|
||||||
|
from textual.app import App
|
||||||
|
|
||||||
from .. import events
|
from .. import events
|
||||||
from ..css._error_tools import friendly_list
|
from ..css._error_tools import friendly_list
|
||||||
from ..reactive import Reactive, reactive
|
from ..reactive import Reactive, reactive
|
||||||
@@ -72,18 +76,13 @@ class Placeholder(Widget):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Consecutive placeholders get assigned consecutive colors.
|
# Consecutive placeholders get assigned consecutive colors.
|
||||||
_COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
|
_COLORS: WeakKeyDictionary[App, Iterator[str]] = WeakKeyDictionary()
|
||||||
_SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]"
|
_SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]"
|
||||||
|
|
||||||
variant: Reactive[PlaceholderVariant] = reactive[PlaceholderVariant]("default")
|
variant: Reactive[PlaceholderVariant] = reactive[PlaceholderVariant]("default")
|
||||||
|
|
||||||
_renderables: dict[PlaceholderVariant, str]
|
_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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
@@ -113,8 +112,6 @@ class Placeholder(Widget):
|
|||||||
|
|
||||||
super().__init__(name=name, id=id, classes=classes)
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
|
|
||||||
self.styles.background = f"{next(Placeholder._COLORS)} 50%"
|
|
||||||
|
|
||||||
self.variant = self.validate_variant(variant)
|
self.variant = self.validate_variant(variant)
|
||||||
"""The current variant of the placeholder."""
|
"""The current variant of the placeholder."""
|
||||||
|
|
||||||
@@ -123,6 +120,13 @@ class Placeholder(Widget):
|
|||||||
while next(self._variants_cycle) != self.variant:
|
while next(self._variants_cycle) != self.variant:
|
||||||
pass
|
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:
|
def render(self) -> RenderableType:
|
||||||
"""Render the placeholder.
|
"""Render the placeholder.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -91,7 +91,6 @@ def test_buttons_render(snap_compare):
|
|||||||
|
|
||||||
def test_placeholder_render(snap_compare):
|
def test_placeholder_render(snap_compare):
|
||||||
# Testing the rendering of the multiple placeholder variants and labels.
|
# Testing the rendering of the multiple placeholder variants and labels.
|
||||||
Placeholder.reset_color_cycle()
|
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "placeholder.py")
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "placeholder.py")
|
||||||
|
|
||||||
|
|
||||||
@@ -261,7 +260,6 @@ PATHS = [
|
|||||||
@pytest.mark.parametrize("file_name", PATHS)
|
@pytest.mark.parametrize("file_name", PATHS)
|
||||||
def test_css_property(file_name, snap_compare):
|
def test_css_property(file_name, snap_compare):
|
||||||
path_to_app = STYLES_EXAMPLES_DIR / file_name
|
path_to_app = STYLES_EXAMPLES_DIR / file_name
|
||||||
Placeholder.reset_color_cycle()
|
|
||||||
assert snap_compare(path_to_app)
|
assert snap_compare(path_to_app)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
28
tests/test_footer.py
Normal file
28
tests/test_footer.py
Normal file
@@ -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
|
||||||
277
tests/test_screen_modes.py
Normal file
277
tests/test_screen_modes.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
from functools import partial
|
||||||
|
from itertools import cycle
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from textual.app import (
|
||||||
|
ActiveModeError,
|
||||||
|
App,
|
||||||
|
ComposeResult,
|
||||||
|
InvalidModeError,
|
||||||
|
UnknownModeError,
|
||||||
|
)
|
||||||
|
from textual.screen import ModalScreen, Screen
|
||||||
|
from textual.widgets import Footer, Header, Label, TextLog
|
||||||
|
|
||||||
|
FRUITS = cycle("apple mango strawberry banana peach pear melon watermelon".split())
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenBindingsMixin(Screen[None]):
|
||||||
|
BINDINGS = [
|
||||||
|
("1", "one", "Mode 1"),
|
||||||
|
("2", "two", "Mode 2"),
|
||||||
|
("p", "push", "Push rnd scrn"),
|
||||||
|
("o", "pop_screen", "Pop"),
|
||||||
|
("r", "remove", "Remove mode 1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def action_one(self) -> None:
|
||||||
|
self.app.switch_mode("one")
|
||||||
|
|
||||||
|
def action_two(self) -> None:
|
||||||
|
self.app.switch_mode("two")
|
||||||
|
|
||||||
|
def action_fruits(self) -> None:
|
||||||
|
self.app.switch_mode("fruits")
|
||||||
|
|
||||||
|
def action_push(self) -> None:
|
||||||
|
self.app.push_screen(FruitModal())
|
||||||
|
|
||||||
|
|
||||||
|
class BaseScreen(ScreenBindingsMixin):
|
||||||
|
def __init__(self, label):
|
||||||
|
super().__init__()
|
||||||
|
self.label = label
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Label(self.label)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def action_remove(self) -> None:
|
||||||
|
self.app.remove_mode("one")
|
||||||
|
|
||||||
|
|
||||||
|
class FruitModal(ModalScreen[str], ScreenBindingsMixin):
|
||||||
|
BINDINGS = [("d", "dismiss_fruit", "Dismiss")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label(next(FRUITS))
|
||||||
|
|
||||||
|
|
||||||
|
class FruitsScreen(ScreenBindingsMixin):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield TextLog()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ModesApp():
|
||||||
|
class ModesApp(App[None]):
|
||||||
|
MODES = {
|
||||||
|
"one": lambda: BaseScreen("one"),
|
||||||
|
"two": "screen_two",
|
||||||
|
}
|
||||||
|
|
||||||
|
SCREENS = {
|
||||||
|
"screen_two": lambda: BaseScreen("two"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
self.switch_mode("one")
|
||||||
|
|
||||||
|
return ModesApp
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mode_setup(ModesApp: Type[App]):
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test():
|
||||||
|
assert isinstance(app.screen, BaseScreen)
|
||||||
|
assert str(app.screen.query_one(Label).renderable) == "one"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_switch_mode(ModesApp: Type[App]):
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("2")
|
||||||
|
assert str(app.screen.query_one(Label).renderable) == "two"
|
||||||
|
await pilot.press("1")
|
||||||
|
assert str(app.screen.query_one(Label).renderable) == "one"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_switch_same_mode(ModesApp: Type[App]):
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("1")
|
||||||
|
assert str(app.screen.query_one(Label).renderable) == "one"
|
||||||
|
await pilot.press("1")
|
||||||
|
assert str(app.screen.query_one(Label).renderable) == "one"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_switch_unknown_mode(ModesApp: Type[App]):
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test():
|
||||||
|
with pytest.raises(UnknownModeError):
|
||||||
|
app.switch_mode("unknown mode here")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remove_mode(ModesApp: Type[App]):
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
app.switch_mode("two")
|
||||||
|
await pilot.pause()
|
||||||
|
assert str(app.screen.query_one(Label).renderable) == "two"
|
||||||
|
app.remove_mode("one")
|
||||||
|
assert "one" not in app.MODES
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remove_active_mode(ModesApp: Type[App]):
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test():
|
||||||
|
with pytest.raises(ActiveModeError):
|
||||||
|
app.remove_mode("one")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_mode(ModesApp: Type[App]):
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
app.add_mode("three", BaseScreen("three"))
|
||||||
|
app.switch_mode("three")
|
||||||
|
await pilot.pause()
|
||||||
|
assert str(app.screen.query_one(Label).renderable) == "three"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_mode_duplicated(ModesApp: Type[App]):
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test():
|
||||||
|
with pytest.raises(InvalidModeError):
|
||||||
|
app.add_mode("one", BaseScreen("one"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_screen_stack_preserved(ModesApp: Type[App]):
|
||||||
|
fruits = []
|
||||||
|
N = 5
|
||||||
|
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# Build the stack up.
|
||||||
|
for _ in range(N):
|
||||||
|
await pilot.press("p")
|
||||||
|
fruits.append(str(app.query_one(Label).renderable))
|
||||||
|
|
||||||
|
assert len(app.screen_stack) == N + 1
|
||||||
|
|
||||||
|
# Switch out and back.
|
||||||
|
await pilot.press("2")
|
||||||
|
assert len(app.screen_stack) == 1
|
||||||
|
await pilot.press("1")
|
||||||
|
|
||||||
|
# Check the stack.
|
||||||
|
assert len(app.screen_stack) == N + 1
|
||||||
|
for _ in range(N):
|
||||||
|
assert str(app.query_one(Label).renderable) == fruits.pop()
|
||||||
|
await pilot.press("o")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inactive_stack_is_alive():
|
||||||
|
"""This tests that timers in screens outside the active stack keep going."""
|
||||||
|
pings = []
|
||||||
|
|
||||||
|
class FastCounter(Screen[None]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("fast")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.set_interval(0.01, self.ping)
|
||||||
|
|
||||||
|
def ping(self) -> None:
|
||||||
|
pings.append(str(self.app.query_one(Label).renderable))
|
||||||
|
|
||||||
|
def key_s(self):
|
||||||
|
self.app.switch_mode("smile")
|
||||||
|
|
||||||
|
class SmileScreen(Screen[None]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label(":)")
|
||||||
|
|
||||||
|
def key_s(self):
|
||||||
|
self.app.switch_mode("fast")
|
||||||
|
|
||||||
|
class ModesApp(App[None]):
|
||||||
|
MODES = {
|
||||||
|
"fast": FastCounter,
|
||||||
|
"smile": SmileScreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.switch_mode("fast")
|
||||||
|
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("s")
|
||||||
|
assert str(app.query_one(Label).renderable) == ":)"
|
||||||
|
await pilot.press("s")
|
||||||
|
assert ":)" in pings
|
||||||
|
|
||||||
|
|
||||||
|
async def test_multiple_mode_callbacks():
|
||||||
|
written = []
|
||||||
|
|
||||||
|
class LogScreen(Screen[None]):
|
||||||
|
def __init__(self, value):
|
||||||
|
super().__init__()
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def key_p(self) -> None:
|
||||||
|
self.app.push_screen(ResultScreen(self.value), written.append)
|
||||||
|
|
||||||
|
class ResultScreen(Screen[str]):
|
||||||
|
def __init__(self, value):
|
||||||
|
super().__init__()
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def key_p(self) -> None:
|
||||||
|
self.dismiss(self.value)
|
||||||
|
|
||||||
|
def key_f(self) -> None:
|
||||||
|
self.app.switch_mode("first")
|
||||||
|
|
||||||
|
def key_o(self) -> None:
|
||||||
|
self.app.switch_mode("other")
|
||||||
|
|
||||||
|
class ModesApp(App[None]):
|
||||||
|
MODES = {
|
||||||
|
"first": lambda: LogScreen("first"),
|
||||||
|
"other": lambda: LogScreen("other"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.switch_mode("first")
|
||||||
|
|
||||||
|
def key_f(self) -> None:
|
||||||
|
self.switch_mode("first")
|
||||||
|
|
||||||
|
def key_o(self) -> None:
|
||||||
|
self.switch_mode("other")
|
||||||
|
|
||||||
|
app = ModesApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# Push and dismiss ResultScreen("first")
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.press("p")
|
||||||
|
assert written == ["first"]
|
||||||
|
|
||||||
|
# Push ResultScreen("first")
|
||||||
|
await pilot.press("p")
|
||||||
|
# Switch to LogScreen("other")
|
||||||
|
await pilot.press("o")
|
||||||
|
# Push and dismiss ResultScreen("other")
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.press("p")
|
||||||
|
assert written == ["first", "other"]
|
||||||
|
|
||||||
|
# Go back to ResultScreen("first")
|
||||||
|
await pilot.press("f")
|
||||||
|
# Dismiss ResultScreen("first")
|
||||||
|
await pilot.press("p")
|
||||||
|
assert written == ["first", "other", "first"]
|
||||||
Reference in New Issue
Block a user