Merge branch 'main' into multiselect

This commit is contained in:
Dave Pearson
2023-05-24 15:08:04 +01:00
16 changed files with 620 additions and 124 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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,
), ),

View File

@@ -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:

View File

@@ -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):

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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
View 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"]