4860 lines
174 KiB
Python
4860 lines
174 KiB
Python
"""
|
|
|
|
Here you will find the [App][textual.app.App] class, which is the base class for Textual apps.
|
|
|
|
See [app basics](/guide/app) for how to build Textual apps.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib
|
|
import inspect
|
|
import io
|
|
import mimetypes
|
|
import os
|
|
import signal
|
|
import sys
|
|
import threading
|
|
import uuid
|
|
import warnings
|
|
from asyncio import AbstractEventLoop, Task, create_task
|
|
from concurrent.futures import Future
|
|
from contextlib import (
|
|
asynccontextmanager,
|
|
contextmanager,
|
|
redirect_stderr,
|
|
redirect_stdout,
|
|
)
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from time import perf_counter
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
AsyncGenerator,
|
|
Awaitable,
|
|
BinaryIO,
|
|
Callable,
|
|
ClassVar,
|
|
Generator,
|
|
Generic,
|
|
Iterable,
|
|
Iterator,
|
|
Mapping,
|
|
NamedTuple,
|
|
Sequence,
|
|
TextIO,
|
|
Type,
|
|
TypeVar,
|
|
overload,
|
|
)
|
|
from weakref import WeakKeyDictionary, WeakSet
|
|
|
|
import rich
|
|
import rich.repr
|
|
from platformdirs import user_downloads_path
|
|
from rich.console import Console, ConsoleDimensions, ConsoleOptions, RenderableType
|
|
from rich.control import Control
|
|
from rich.protocol import is_renderable
|
|
from rich.segment import Segment, Segments
|
|
from rich.terminal_theme import TerminalTheme
|
|
|
|
from textual import (
|
|
Logger,
|
|
LogGroup,
|
|
LogVerbosity,
|
|
actions,
|
|
constants,
|
|
events,
|
|
log,
|
|
messages,
|
|
on,
|
|
)
|
|
from textual._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
|
|
from textual._ansi_sequences import SYNC_END, SYNC_START
|
|
from textual._ansi_theme import ALABASTER, MONOKAI
|
|
from textual._callback import invoke
|
|
from textual._compat import cached_property
|
|
from textual._compositor import CompositorUpdate
|
|
from textual._context import active_app, active_message_pump
|
|
from textual._context import message_hook as message_hook_context_var
|
|
from textual._dispatch_key import dispatch_key
|
|
from textual._event_broker import NoHandler, extract_handler_actions
|
|
from textual._files import generate_datetime_filename
|
|
from textual._path import (
|
|
CSSPathType,
|
|
_css_path_type_as_list,
|
|
_make_path_object_relative,
|
|
)
|
|
from textual._types import AnimationLevel
|
|
from textual._wait import wait_for_idle
|
|
from textual.actions import ActionParseResult, SkipAction
|
|
from textual.await_complete import AwaitComplete
|
|
from textual.await_remove import AwaitRemove
|
|
from textual.binding import Binding, BindingsMap, BindingType, Keymap
|
|
from textual.command import CommandListItem, CommandPalette, Provider, SimpleProvider
|
|
from textual.compose import compose
|
|
from textual.content import Content
|
|
from textual.css.errors import StylesheetError
|
|
from textual.css.query import NoMatches
|
|
from textual.css.stylesheet import RulesMap, Stylesheet
|
|
from textual.dom import DOMNode, NoScreen
|
|
from textual.driver import Driver
|
|
from textual.errors import NoWidget
|
|
from textual.features import FeatureFlag, parse_features
|
|
from textual.file_monitor import FileMonitor
|
|
from textual.filter import ANSIToTruecolor, DimFilter, Monochrome, NoColor
|
|
from textual.geometry import Offset, Region, Size
|
|
from textual.keys import (
|
|
REPLACED_KEYS,
|
|
_character_to_key,
|
|
_get_unicode_name_from_key,
|
|
_normalize_key_list,
|
|
format_key,
|
|
)
|
|
from textual.messages import CallbackType, Prune
|
|
from textual.notifications import Notification, Notifications, Notify, SeverityLevel
|
|
from textual.reactive import Reactive
|
|
from textual.renderables.blank import Blank
|
|
from textual.screen import (
|
|
ActiveBinding,
|
|
Screen,
|
|
ScreenResultCallbackType,
|
|
ScreenResultType,
|
|
SystemModalScreen,
|
|
)
|
|
from textual.signal import Signal
|
|
from textual.theme import BUILTIN_THEMES, Theme, ThemeProvider
|
|
from textual.timer import Timer
|
|
from textual.visual import SupportsVisual, Visual
|
|
from textual.widget import AwaitMount, Widget
|
|
from textual.widgets._toast import ToastRack
|
|
from textual.worker import NoActiveWorker, get_current_worker
|
|
from textual.worker_manager import WorkerManager
|
|
|
|
if TYPE_CHECKING:
|
|
from textual_dev.client import DevtoolsClient
|
|
from typing_extensions import Coroutine, Literal, Self, TypeAlias
|
|
|
|
from textual._types import MessageTarget
|
|
|
|
# Unused & ignored imports are needed for the docs to link to these objects:
|
|
from textual.css.query import WrongType # type: ignore # noqa: F401
|
|
from textual.filter import LineFilter
|
|
from textual.message import Message
|
|
from textual.pilot import Pilot
|
|
from textual.system_commands import SystemCommandsProvider
|
|
from textual.widget import MountError # type: ignore # noqa: F401
|
|
|
|
WINDOWS = sys.platform == "win32"
|
|
|
|
# asyncio will warn against resources not being cleared
|
|
if constants.DEBUG:
|
|
warnings.simplefilter("always", ResourceWarning)
|
|
|
|
# `asyncio.get_event_loop()` is deprecated since Python 3.10:
|
|
_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0)
|
|
|
|
ComposeResult = Iterable[Widget]
|
|
RenderResult: TypeAlias = "RenderableType | Visual | SupportsVisual"
|
|
"""Result of Widget.render()"""
|
|
|
|
AutopilotCallbackType: TypeAlias = (
|
|
"Callable[[Pilot[object]], Coroutine[Any, Any, None]]"
|
|
)
|
|
"""Signature for valid callbacks that can be used to control apps."""
|
|
|
|
CommandCallback: TypeAlias = "Callable[[], Awaitable[Any]] | Callable[[], Any]"
|
|
"""Signature for callbacks used in [`get_system_commands`][textual.app.App.get_system_commands]"""
|
|
|
|
ScreenType = TypeVar("ScreenType", bound=Screen)
|
|
"""Type var for a Screen, used in [`get_screen`][textual.app.App.get_screen]."""
|
|
|
|
|
|
class SystemCommand(NamedTuple):
|
|
"""Defines a system command used in the command palette (yielded from [`get_system_commands`][textual.app.App.get_system_commands])."""
|
|
|
|
title: str
|
|
"""The title of the command (used in search)."""
|
|
help: str
|
|
"""Additional help text, shown under the title."""
|
|
callback: CommandCallback
|
|
"""A callback to invoke when the command is selected."""
|
|
discover: bool = True
|
|
"""Should the command show when the search is empty?"""
|
|
|
|
|
|
def get_system_commands_provider() -> type[SystemCommandsProvider]:
|
|
"""Callable to lazy load the system commands.
|
|
|
|
Returns:
|
|
System commands class.
|
|
"""
|
|
from textual.system_commands import SystemCommandsProvider
|
|
|
|
return SystemCommandsProvider
|
|
|
|
|
|
class AppError(Exception):
|
|
"""Base class for general App related exceptions."""
|
|
|
|
|
|
class ActionError(Exception):
|
|
"""Base class for exceptions relating to actions."""
|
|
|
|
|
|
class ScreenError(Exception):
|
|
"""Base class for exceptions that relate to screens."""
|
|
|
|
|
|
class ScreenStackError(ScreenError):
|
|
"""Raised when trying to manipulate the screen stack incorrectly."""
|
|
|
|
|
|
class ModeError(Exception):
|
|
"""Base class for exceptions related to modes."""
|
|
|
|
|
|
class InvalidModeError(ModeError):
|
|
"""Raised if there is an issue with a mode name."""
|
|
|
|
|
|
class UnknownModeError(ModeError):
|
|
"""Raised when attempting to use a mode that is not known."""
|
|
|
|
|
|
class ActiveModeError(ModeError):
|
|
"""Raised when attempting to remove the currently active mode."""
|
|
|
|
|
|
class SuspendNotSupported(Exception):
|
|
"""Raised if suspending the application is not supported.
|
|
|
|
This exception is raised if [`App.suspend`][textual.app.App.suspend] is called while
|
|
the application is running in an environment where this isn't supported.
|
|
"""
|
|
|
|
|
|
class InvalidThemeError(Exception):
|
|
"""Raised when an invalid theme is set."""
|
|
|
|
|
|
ReturnType = TypeVar("ReturnType")
|
|
CallThreadReturnType = TypeVar("CallThreadReturnType")
|
|
|
|
|
|
class _NullFile:
|
|
"""A file-like where writes go nowhere."""
|
|
|
|
def write(self, text: str) -> None:
|
|
pass
|
|
|
|
def flush(self) -> None:
|
|
pass
|
|
|
|
def isatty(self) -> bool:
|
|
return True
|
|
|
|
|
|
class _PrintCapture:
|
|
"""A file-like which captures output."""
|
|
|
|
def __init__(self, app: App, stderr: bool = False) -> None:
|
|
"""
|
|
|
|
Args:
|
|
app: App instance.
|
|
stderr: Write from stderr.
|
|
"""
|
|
self.app = app
|
|
self.stderr = stderr
|
|
|
|
def write(self, text: str) -> None:
|
|
"""Called when writing to stdout or stderr.
|
|
|
|
Args:
|
|
text: Text that was "printed".
|
|
"""
|
|
self.app._print(text, stderr=self.stderr)
|
|
|
|
def flush(self) -> None:
|
|
"""Called when stdout or stderr was flushed."""
|
|
self.app._flush(stderr=self.stderr)
|
|
|
|
def isatty(self) -> bool:
|
|
"""Pretend we're a terminal."""
|
|
# TODO: should this be configurable?
|
|
return True
|
|
|
|
def fileno(self) -> int:
|
|
"""Return invalid fileno."""
|
|
return -1
|
|
|
|
|
|
@rich.repr.auto
|
|
class App(Generic[ReturnType], DOMNode):
|
|
"""The base class for Textual Applications."""
|
|
|
|
CSS: ClassVar[str] = ""
|
|
"""Inline CSS, useful for quick scripts. This is loaded after CSS_PATH,
|
|
and therefore takes priority in the event of a specificity clash."""
|
|
|
|
# Default (the lowest priority) CSS
|
|
DEFAULT_CSS: ClassVar[str]
|
|
DEFAULT_CSS = """
|
|
App {
|
|
background: $background;
|
|
color: $foreground;
|
|
|
|
&:ansi {
|
|
background: ansi_default;
|
|
color: ansi_default;
|
|
|
|
.-ansi-scrollbar {
|
|
scrollbar-background: ansi_default;
|
|
scrollbar-background-hover: ansi_default;
|
|
scrollbar-background-active: ansi_default;
|
|
scrollbar-color: ansi_blue;
|
|
scrollbar-color-active: ansi_bright_blue;
|
|
scrollbar-color-hover: ansi_bright_blue;
|
|
scrollbar-corner-color: ansi_default;
|
|
}
|
|
|
|
.bindings-table--key {
|
|
color: ansi_magenta;
|
|
}
|
|
.bindings-table--description {
|
|
color: ansi_default;
|
|
}
|
|
|
|
.bindings-table--header {
|
|
color: ansi_default;
|
|
}
|
|
|
|
.bindings-table--divider {
|
|
color: transparent;
|
|
text-style: dim;
|
|
}
|
|
}
|
|
|
|
/* When a widget is maximized */
|
|
Screen.-maximized-view {
|
|
layout: vertical !important;
|
|
hatch: right $panel;
|
|
overflow-y: auto !important;
|
|
align: center middle;
|
|
.-maximized {
|
|
dock: initial !important;
|
|
}
|
|
}
|
|
/* Fade the header title when app is blurred */
|
|
&:blur HeaderTitle {
|
|
text-opacity: 50%;
|
|
}
|
|
}
|
|
*:disabled:can-focus {
|
|
opacity: 0.7;
|
|
}
|
|
"""
|
|
|
|
MODES: ClassVar[dict[str, str | 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,
|
|
}
|
|
|
|
...
|
|
```
|
|
"""
|
|
DEFAULT_MODE: ClassVar[str] = "_default"
|
|
"""Name of the default mode."""
|
|
|
|
SCREENS: ClassVar[dict[str, Callable[[], Screen[Any]]]] = {}
|
|
"""Screens associated with the app for the lifetime of the app."""
|
|
|
|
AUTO_FOCUS: ClassVar[str | None] = "*"
|
|
"""A selector to determine what to focus automatically when a screen is activated.
|
|
|
|
The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors).
|
|
Setting to `None` or `""` disables auto focus.
|
|
"""
|
|
|
|
ALLOW_SELECT: ClassVar[bool] = True
|
|
"""A switch to toggle arbitrary text selection for the app.
|
|
|
|
Note that this doesn't apply to Input and TextArea which have builtin support for selection.
|
|
"""
|
|
|
|
_BASE_PATH: str | None = None
|
|
CSS_PATH: ClassVar[CSSPathType | None] = None
|
|
"""File paths to load CSS from."""
|
|
|
|
TITLE: str | None = None
|
|
"""A class variable to set the *default* title for the application.
|
|
|
|
To update the title while the app is running, you can set the [title][textual.app.App.title] attribute.
|
|
See also [the `Screen.TITLE` attribute][textual.screen.Screen.TITLE].
|
|
"""
|
|
|
|
SUB_TITLE: str | None = None
|
|
"""A class variable to set the default sub-title for the application.
|
|
|
|
To update the sub-title while the app is running, you can set the [sub_title][textual.app.App.sub_title] attribute.
|
|
See also [the `Screen.SUB_TITLE` attribute][textual.screen.Screen.SUB_TITLE].
|
|
"""
|
|
|
|
ENABLE_COMMAND_PALETTE: ClassVar[bool] = True
|
|
"""Should the [command palette][textual.command.CommandPalette] be enabled for the application?"""
|
|
|
|
NOTIFICATION_TIMEOUT: ClassVar[float] = 5
|
|
"""Default number of seconds to show notifications before removing them."""
|
|
|
|
COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = {
|
|
get_system_commands_provider
|
|
}
|
|
"""Command providers used by the [command palette](/guide/command_palette).
|
|
|
|
Should be a set of [command.Provider][textual.command.Provider] classes.
|
|
"""
|
|
|
|
COMMAND_PALETTE_BINDING: ClassVar[str] = "ctrl+p"
|
|
"""The key that launches the command palette (if enabled by [`App.ENABLE_COMMAND_PALETTE`][textual.app.App.ENABLE_COMMAND_PALETTE])."""
|
|
|
|
COMMAND_PALETTE_DISPLAY: ClassVar[str | None] = None
|
|
"""How the command palette key should be displayed in the footer (or `None` for default)."""
|
|
|
|
ALLOW_IN_MAXIMIZED_VIEW: ClassVar[str] = "Footer"
|
|
"""The default value of [Screen.ALLOW_IN_MAXIMIZED_VIEW][textual.screen.Screen.ALLOW_IN_MAXIMIZED_VIEW]."""
|
|
|
|
CLICK_CHAIN_TIME_THRESHOLD: ClassVar[float] = 0.5
|
|
"""The maximum number of seconds between clicks to upgrade a single click to a double click,
|
|
a double click to a triple click, etc."""
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding(
|
|
"ctrl+q",
|
|
"quit",
|
|
"Quit",
|
|
tooltip="Quit the app and return to the command prompt.",
|
|
show=False,
|
|
priority=True,
|
|
),
|
|
Binding("ctrl+c", "help_quit", show=False, system=True),
|
|
]
|
|
"""The default key bindings."""
|
|
|
|
CLOSE_TIMEOUT: float | None = 5.0
|
|
"""Timeout waiting for widget's to close, or `None` for no timeout."""
|
|
|
|
TOOLTIP_DELAY: float = 0.5
|
|
"""The time in seconds after which a tooltip gets displayed."""
|
|
|
|
BINDING_GROUP_TITLE: str | None = None
|
|
"""Set to text to show in the key panel."""
|
|
|
|
ESCAPE_TO_MINIMIZE: ClassVar[bool] = True
|
|
"""Use escape key to minimize widgets (potentially overriding bindings).
|
|
|
|
This is the default value, used if the active screen's `ESCAPE_TO_MINIMIZE` is not changed from `None`.
|
|
"""
|
|
|
|
INLINE_PADDING: ClassVar[int] = 1
|
|
"""Number of blank lines above an inline app."""
|
|
|
|
SUSPENDED_SCREEN_CLASS: ClassVar[str] = ""
|
|
"""Class to apply to suspended screens, or empty string for no class."""
|
|
|
|
HORIZONTAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = []
|
|
"""List of horizontal breakpoints for responsive classes.
|
|
|
|
This allows for styles to be responsive to the dimensions of the terminal.
|
|
For instance, you might want to show less information, or fewer columns on a narrow displays -- or more information when the terminal is sized wider than usual.
|
|
|
|
A breakpoint consists of a tuple containing the minimum width where the class should applied, and the name of the class to set.
|
|
|
|
Note that only one class name is set, and if you should avoid having more than one breakpoint set for the same size.
|
|
|
|
Example:
|
|
```python
|
|
# Up to 80 cells wide, the app has the class "-normal"
|
|
# 80 - 119 cells wide, the app has the class "-wide"
|
|
# 120 cells or wider, the app has the class "-very-wide"
|
|
HORIZONTAL_BREAKPOINTS = [(0, "-normal"), (80, "-wide"), (120, "-very-wide")]
|
|
```
|
|
|
|
"""
|
|
VERTICAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = []
|
|
"""List of vertical breakpoints for responsive classes.
|
|
|
|
Contents are the same as [`HORIZONTAL_BREAKPOINTS`][textual.app.App.HORIZONTAL_BREAKPOINTS], but the integer is compared to the height, rather than the width.
|
|
"""
|
|
|
|
_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = {
|
|
"focus": lambda app: app.app_focus,
|
|
"blur": lambda app: not app.app_focus,
|
|
"dark": lambda app: app.current_theme.dark,
|
|
"light": lambda app: not app.current_theme.dark,
|
|
"inline": lambda app: app.is_inline,
|
|
"ansi": lambda app: app.ansi_color,
|
|
"nocolor": lambda app: app.no_color,
|
|
}
|
|
|
|
title: Reactive[str] = Reactive("", compute=False)
|
|
"""The title of the app, displayed in the header."""
|
|
sub_title: Reactive[str] = Reactive("", compute=False)
|
|
"""The app's sub-title, combined with [`title`][textual.app.App.title] in the header."""
|
|
|
|
app_focus = Reactive(True, compute=False)
|
|
"""Indicates if the app has focus.
|
|
|
|
When run in the terminal, the app always has focus. When run in the web, the app will
|
|
get focus when the terminal widget has focus.
|
|
"""
|
|
|
|
theme: Reactive[str] = Reactive(constants.DEFAULT_THEME)
|
|
"""The name of the currently active theme."""
|
|
|
|
ansi_theme_dark = Reactive(MONOKAI, init=False)
|
|
"""Maps ANSI colors to hex colors using a Rich TerminalTheme object while using a dark theme."""
|
|
|
|
ansi_theme_light = Reactive(ALABASTER, init=False)
|
|
"""Maps ANSI colors to hex colors using a Rich TerminalTheme object while using a light theme."""
|
|
|
|
ansi_color = Reactive(False)
|
|
"""Allow ANSI colors in UI?"""
|
|
|
|
def __init__(
|
|
self,
|
|
driver_class: Type[Driver] | None = None,
|
|
css_path: CSSPathType | None = None,
|
|
watch_css: bool = False,
|
|
ansi_color: bool = False,
|
|
):
|
|
"""Create an instance of an app.
|
|
|
|
Args:
|
|
driver_class: Driver class or `None` to auto-detect.
|
|
This will be used by some Textual tools.
|
|
css_path: Path to CSS or `None` to use the `CSS_PATH` class variable.
|
|
To load multiple CSS files, pass a list of strings or paths which
|
|
will be loaded in order.
|
|
watch_css: Reload CSS if the files changed. This is set automatically if
|
|
you are using `textual run` with the `dev` switch.
|
|
ansi_color: Allow ANSI colors if `True`, or convert ANSI colors to RGB if `False`.
|
|
|
|
Raises:
|
|
CssPathError: When the supplied CSS path(s) are an unexpected type.
|
|
"""
|
|
self._start_time = perf_counter()
|
|
super().__init__(classes=self.DEFAULT_CLASSES)
|
|
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
|
|
|
|
self._registered_themes: dict[str, Theme] = {}
|
|
"""Themes that have been registered with the App using `App.register_theme`.
|
|
|
|
This excludes the built-in themes."""
|
|
|
|
for theme in BUILTIN_THEMES.values():
|
|
self.register_theme(theme)
|
|
|
|
ansi_theme = (
|
|
self.ansi_theme_dark if self.current_theme.dark else self.ansi_theme_light
|
|
)
|
|
self.set_reactive(App.ansi_color, ansi_color)
|
|
self._filters: list[LineFilter] = [
|
|
ANSIToTruecolor(ansi_theme, enabled=not ansi_color)
|
|
]
|
|
environ = dict(os.environ)
|
|
self.no_color = environ.pop("NO_COLOR", None) is not None
|
|
if self.no_color:
|
|
self._filters.append(NoColor() if self.ansi_color else Monochrome())
|
|
|
|
for filter_name in constants.FILTERS.split(","):
|
|
filter = filter_name.lower().strip()
|
|
if filter == "dim":
|
|
self._filters.append(DimFilter())
|
|
|
|
self.console = Console(
|
|
color_system=constants.COLOR_SYSTEM,
|
|
file=_NullFile(),
|
|
markup=True,
|
|
highlight=False,
|
|
emoji=False,
|
|
legacy_windows=False,
|
|
_environ=environ,
|
|
force_terminal=True,
|
|
safe_box=False,
|
|
soft_wrap=False,
|
|
)
|
|
self._workers = WorkerManager(self)
|
|
self.error_console = Console(markup=False, highlight=False, stderr=True)
|
|
self.driver_class = driver_class or self.get_driver_class()
|
|
self._screen_stacks: dict[str, list[Screen[Any]]] = {self.DEFAULT_MODE: []}
|
|
"""A stack of screens per mode."""
|
|
self._current_mode: str = self.DEFAULT_MODE
|
|
"""The current mode the app is in."""
|
|
self._sync_available = False
|
|
|
|
self.mouse_over: Widget | None = None
|
|
"""The widget directly under the mouse."""
|
|
self.hover_over: Widget | None = None
|
|
"""The first widget with a hover style under the mouse."""
|
|
self.mouse_captured: Widget | None = None
|
|
self._driver: Driver | None = None
|
|
self._exit_renderables: list[RenderableType] = []
|
|
|
|
self._action_targets = {"app", "screen", "focused"}
|
|
self._animator = Animator(self)
|
|
self._animate = self._animator.bind(self)
|
|
self.mouse_position = Offset(0, 0)
|
|
|
|
self._mouse_down_widget: Widget | None = None
|
|
"""The widget that was most recently mouse downed (used to create click events)."""
|
|
|
|
self._click_chain_last_offset: Offset | None = None
|
|
"""The last offset at which a Click occurred, in screen-space."""
|
|
|
|
self._click_chain_last_time: float | None = None
|
|
"""The last time at which a Click occurred."""
|
|
|
|
self._chained_clicks: int = 1
|
|
"""Counter which tracks the number of clicks received in a row."""
|
|
|
|
self._previous_cursor_position = Offset(0, 0)
|
|
"""The previous cursor position"""
|
|
|
|
self.cursor_position = Offset(0, 0)
|
|
"""The position of the terminal cursor in screen-space.
|
|
|
|
This can be set by widgets and is useful for controlling the
|
|
positioning of OS IME and emoji popup menus."""
|
|
|
|
self._exception: Exception | None = None
|
|
"""The unhandled exception which is leading to the app shutting down,
|
|
or None if the app is still running with no unhandled exceptions."""
|
|
|
|
self.title = (
|
|
self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}"
|
|
)
|
|
"""The title for the application.
|
|
|
|
The initial value for `title` will be set to the `TITLE` class variable if it exists, or
|
|
the name of the app if it doesn't.
|
|
|
|
Assign a new value to this attribute to change the title.
|
|
The new value is always converted to string.
|
|
"""
|
|
|
|
self.sub_title = self.SUB_TITLE if self.SUB_TITLE is not None else ""
|
|
"""The sub-title for the application.
|
|
|
|
The initial value for `sub_title` will be set to the `SUB_TITLE` class variable if it exists, or
|
|
an empty string if it doesn't.
|
|
|
|
Sub-titles are typically used to show the high-level state of the app, such as the current mode, or path to
|
|
the file being worked on.
|
|
|
|
Assign a new value to this attribute to change the sub-title.
|
|
The new value is always converted to string.
|
|
"""
|
|
|
|
self.use_command_palette: bool = self.ENABLE_COMMAND_PALETTE
|
|
"""A flag to say if the application should use the command palette.
|
|
|
|
If set to `False` any call to
|
|
[`action_command_palette`][textual.app.App.action_command_palette]
|
|
will be ignored.
|
|
"""
|
|
|
|
self._logger = Logger(self._log, app=self)
|
|
|
|
self._css_has_errors = False
|
|
|
|
self.theme_variables: dict[str, str] = {}
|
|
"""Variables generated from the current theme."""
|
|
|
|
# Note that the theme must be set *before* self.get_css_variables() is called
|
|
# to ensure that the variables are retrieved from the currently active theme.
|
|
self.stylesheet = Stylesheet(variables=self.get_css_variables())
|
|
|
|
css_path = css_path or self.CSS_PATH
|
|
css_paths = [
|
|
_make_path_object_relative(css_path, self)
|
|
for css_path in (
|
|
_css_path_type_as_list(css_path) if css_path is not None else []
|
|
)
|
|
]
|
|
self.css_path = css_paths
|
|
|
|
self._registry: WeakSet[DOMNode] = WeakSet()
|
|
|
|
self._keymap: Keymap = {}
|
|
|
|
# Sensitivity on X is double the sensitivity on Y to account for
|
|
# cells being twice as tall as wide
|
|
self.scroll_sensitivity_x: float = 4.0
|
|
"""Number of columns to scroll in the X direction with wheel or trackpad."""
|
|
self.scroll_sensitivity_y: float = 2.0
|
|
"""Number of lines to scroll in the Y direction with wheel or trackpad."""
|
|
|
|
self._installed_screens: dict[str, Screen | Callable[[], Screen]] = {}
|
|
self._installed_screens.update(**self.SCREENS)
|
|
self._modes: dict[str, str | Callable[[], Screen]] = self.MODES.copy()
|
|
"""Contains the working-copy of the `MODES` for each instance."""
|
|
|
|
self._compose_stacks: list[list[Widget]] = []
|
|
self._composed: list[list[Widget]] = []
|
|
self._recompose_required = False
|
|
|
|
self.devtools: DevtoolsClient | None = None
|
|
self._devtools_redirector: StdoutRedirector | None = None
|
|
if "devtools" in self.features:
|
|
try:
|
|
from textual_dev.client import DevtoolsClient
|
|
from textual_dev.redirect_output import StdoutRedirector
|
|
except ImportError:
|
|
# Dev dependencies not installed
|
|
pass
|
|
else:
|
|
self.devtools = DevtoolsClient(constants.DEVTOOLS_HOST)
|
|
self._devtools_redirector = StdoutRedirector(self.devtools)
|
|
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._return_value: ReturnType | None = None
|
|
"""Internal attribute used to set the return value for the app."""
|
|
self._return_code: int | None = None
|
|
"""Internal attribute used to set the return code for the app."""
|
|
self._exit = False
|
|
self._disable_tooltips = False
|
|
self._disable_notifications = False
|
|
|
|
self.css_monitor = (
|
|
FileMonitor(self.css_path, self._on_css_change)
|
|
if watch_css or self.debug
|
|
else None
|
|
)
|
|
self._screenshot: str | None = None
|
|
self._dom_ready = False
|
|
self._batch_count = 0
|
|
self._notifications = Notifications()
|
|
|
|
self._capture_print: WeakKeyDictionary[MessageTarget, tuple[bool, bool]] = (
|
|
WeakKeyDictionary()
|
|
)
|
|
"""Registry of the MessageTargets which are capturing output at any given time."""
|
|
self._capture_stdout = _PrintCapture(self, stderr=False)
|
|
"""File-like object capturing data written to stdout."""
|
|
self._capture_stderr = _PrintCapture(self, stderr=True)
|
|
"""File-like object capturing data written to stderr."""
|
|
self._original_stdout = sys.__stdout__
|
|
"""The original stdout stream (before redirection etc)."""
|
|
self._original_stderr = sys.__stderr__
|
|
"""The original stderr stream (before redirection etc)."""
|
|
|
|
self.theme_changed_signal: Signal[Theme] = Signal(self, "theme-changed")
|
|
"""Signal that is published when the App's theme is changed.
|
|
|
|
Subscribers will receive the new theme object as an argument to the callback.
|
|
"""
|
|
|
|
self.app_suspend_signal: Signal[App] = Signal(self, "app-suspend")
|
|
"""The signal that is published when the app is suspended.
|
|
|
|
When [`App.suspend`][textual.app.App.suspend] is called this signal
|
|
will be [published][textual.signal.Signal.publish];
|
|
[subscribe][textual.signal.Signal.subscribe] to this signal to
|
|
perform work before the suspension takes place.
|
|
"""
|
|
self.app_resume_signal: Signal[App] = Signal(self, "app-resume")
|
|
"""The signal that is published when the app is resumed after a suspend.
|
|
|
|
When the app is resumed after a
|
|
[`App.suspend`][textual.app.App.suspend] call this signal will be
|
|
[published][textual.signal.Signal.publish];
|
|
[subscribe][textual.signal.Signal.subscribe] to this signal to
|
|
perform work after the app has resumed.
|
|
"""
|
|
|
|
self.set_class(self.current_theme.dark, "-dark-mode", update=False)
|
|
self.set_class(not self.current_theme.dark, "-light-mode", update=False)
|
|
|
|
self.animation_level: AnimationLevel = constants.TEXTUAL_ANIMATIONS
|
|
"""Determines what type of animations the app will display.
|
|
|
|
See [`textual.constants.TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS].
|
|
"""
|
|
|
|
self._last_focused_on_app_blur: Widget | None = None
|
|
"""The widget that had focus when the last `AppBlur` happened.
|
|
|
|
This will be used to restore correct focus when an `AppFocus`
|
|
happens.
|
|
"""
|
|
|
|
self._previous_inline_height: int | None = None
|
|
"""Size of previous inline update."""
|
|
|
|
self._resize_event: events.Resize | None = None
|
|
"""A pending resize event, sent on idle."""
|
|
|
|
self._size: Size | None = None
|
|
|
|
self._css_update_count: int = 0
|
|
"""Incremented when CSS is invalidated."""
|
|
|
|
self._clipboard: str = ""
|
|
"""Contents of local clipboard."""
|
|
|
|
self.supports_smooth_scrolling: bool = False
|
|
"""Does the terminal support smooth scrolling?"""
|
|
|
|
self._compose_screen: Screen | None = None
|
|
"""The screen composed by App.compose."""
|
|
|
|
if self.ENABLE_COMMAND_PALETTE:
|
|
for _key, binding in self._bindings:
|
|
if binding.action in {"command_palette", "app.command_palette"}:
|
|
break
|
|
else:
|
|
self._bindings._add_binding(
|
|
Binding(
|
|
self.COMMAND_PALETTE_BINDING,
|
|
"command_palette",
|
|
"palette",
|
|
show=False,
|
|
key_display=self.COMMAND_PALETTE_DISPLAY,
|
|
priority=True,
|
|
tooltip="Open the command palette",
|
|
)
|
|
)
|
|
|
|
def get_line_filters(self) -> Sequence[LineFilter]:
|
|
"""Get currently enabled line filters.
|
|
|
|
Returns:
|
|
A list of [LineFilter][textual.filters.LineFilter] instances.
|
|
"""
|
|
return [filter for filter in self._filters if filter.enabled]
|
|
|
|
@property
|
|
def _is_devtools_connected(self) -> bool:
|
|
"""Is the app connected to the devtools?"""
|
|
return self.devtools is not None and self.devtools.is_connected
|
|
|
|
@cached_property
|
|
def _exception_event(self) -> asyncio.Event:
|
|
"""An event that will be set when the first exception is encountered."""
|
|
return asyncio.Event()
|
|
|
|
def __init_subclass__(cls, *args, **kwargs) -> None:
|
|
for variable_name, screen_collection in (
|
|
("SCREENS", cls.SCREENS),
|
|
("MODES", cls.MODES),
|
|
):
|
|
for screen_name, screen_object in screen_collection.items():
|
|
if not (isinstance(screen_object, str) or callable(screen_object)):
|
|
if isinstance(screen_object, Screen):
|
|
raise ValueError(
|
|
f"{variable_name} should contain a Screen type or callable, not an instance"
|
|
f" (got instance of {type(screen_object).__name__} for {screen_name!r})"
|
|
)
|
|
raise TypeError(
|
|
f"expected a callable or string, got {screen_object!r}"
|
|
)
|
|
|
|
return super().__init_subclass__(*args, **kwargs)
|
|
|
|
def _thread_init(self):
|
|
"""Initialize threading primitives for the current thread.
|
|
|
|
https://github.com/Textualize/textual/issues/5845
|
|
|
|
"""
|
|
self._message_queue
|
|
self._mounted_event
|
|
self._exception_event
|
|
self._thread_id = threading.get_ident()
|
|
|
|
def _get_dom_base(self) -> DOMNode:
|
|
"""When querying from the app, we want to query the default screen."""
|
|
return self.default_screen
|
|
|
|
def validate_title(self, title: Any) -> str:
|
|
"""Make sure the title is set to a string."""
|
|
return str(title)
|
|
|
|
def validate_sub_title(self, sub_title: Any) -> str:
|
|
"""Make sure the subtitle is set to a string."""
|
|
return str(sub_title)
|
|
|
|
@property
|
|
def default_screen(self) -> Screen:
|
|
"""The default screen instance."""
|
|
return self.screen if self._compose_screen is None else self._compose_screen
|
|
|
|
@property
|
|
def workers(self) -> WorkerManager:
|
|
"""The [worker](/guide/workers/) manager.
|
|
|
|
Returns:
|
|
An object to manage workers.
|
|
"""
|
|
return self._workers
|
|
|
|
@property
|
|
def return_value(self) -> ReturnType | None:
|
|
"""The return value of the app, or `None` if it has not yet been set.
|
|
|
|
The return value is set when calling [exit][textual.app.App.exit].
|
|
"""
|
|
return self._return_value
|
|
|
|
@property
|
|
def return_code(self) -> int | None:
|
|
"""The return code with which the app exited.
|
|
|
|
Non-zero codes indicate errors.
|
|
A value of 1 means the app exited with a fatal error.
|
|
If the app hasn't exited yet, this will be `None`.
|
|
|
|
Example:
|
|
The return code can be used to exit the process via `sys.exit`.
|
|
```py
|
|
my_app.run()
|
|
sys.exit(my_app.return_code)
|
|
```
|
|
"""
|
|
return self._return_code
|
|
|
|
@property
|
|
def children(self) -> Sequence["Widget"]:
|
|
"""A view onto the app's immediate children.
|
|
|
|
This attribute exists on all widgets.
|
|
In the case of the App, it will only ever contain a single child, which will
|
|
be the currently active screen.
|
|
|
|
Returns:
|
|
A sequence of widgets.
|
|
"""
|
|
try:
|
|
return (
|
|
next(
|
|
screen
|
|
for screen in reversed(self._screen_stack)
|
|
if not isinstance(screen, SystemModalScreen)
|
|
),
|
|
)
|
|
except StopIteration:
|
|
return ()
|
|
|
|
@property
|
|
def clipboard(self) -> str:
|
|
"""The value of the local clipboard.
|
|
|
|
Note, that this only contains text copied in the app, and not
|
|
text copied from elsewhere in the OS.
|
|
"""
|
|
return self._clipboard
|
|
|
|
def format_title(self, title: str, sub_title: str) -> Content:
|
|
"""Format the title for display.
|
|
|
|
Args:
|
|
title: The title.
|
|
sub_title: The sub title.
|
|
|
|
Returns:
|
|
Content instance with title and subtitle.
|
|
"""
|
|
title_content = Content(title)
|
|
sub_title_content = Content(sub_title)
|
|
if sub_title_content:
|
|
return Content.assemble(
|
|
title_content,
|
|
(" — ", "dim"),
|
|
sub_title_content.stylize("dim"),
|
|
)
|
|
else:
|
|
return title_content
|
|
|
|
@contextmanager
|
|
def batch_update(self) -> Generator[None, None, None]:
|
|
"""A context manager to suspend all repaints until the end of the batch."""
|
|
self._begin_batch()
|
|
try:
|
|
yield
|
|
finally:
|
|
self._end_batch()
|
|
|
|
def _begin_batch(self) -> None:
|
|
"""Begin a batch update."""
|
|
self._batch_count += 1
|
|
|
|
def _end_batch(self) -> None:
|
|
"""End a batch update."""
|
|
self._batch_count -= 1
|
|
assert self._batch_count >= 0, "This won't happen if you use `batch_update`"
|
|
if not self._batch_count:
|
|
self.check_idle()
|
|
|
|
def delay_update(self, delay: float = 0.05) -> None:
|
|
"""Delay updates for a short period of time.
|
|
|
|
May be used to mask a brief transition.
|
|
Consider this method only if you aren't able to use `App.batch_update`.
|
|
|
|
Args:
|
|
delay: Delay before updating.
|
|
"""
|
|
self._begin_batch()
|
|
|
|
def end_batch() -> None:
|
|
"""Re-enable updates, and refresh screen."""
|
|
self._end_batch()
|
|
if not self._batch_count:
|
|
self.screen.refresh()
|
|
|
|
self.set_timer(delay, end_batch, name="delay_update")
|
|
|
|
@contextmanager
|
|
def _context(self) -> Generator[None, None, None]:
|
|
"""Context manager to set ContextVars."""
|
|
app_reset_token = active_app.set(self)
|
|
message_pump_reset_token = active_message_pump.set(self)
|
|
try:
|
|
yield
|
|
finally:
|
|
active_message_pump.reset(message_pump_reset_token)
|
|
active_app.reset(app_reset_token)
|
|
|
|
def _watch_ansi_color(self, ansi_color: bool) -> None:
|
|
"""Enable or disable the truecolor filter when the reactive changes"""
|
|
for filter in self._filters:
|
|
if isinstance(filter, ANSIToTruecolor):
|
|
filter.enabled = not ansi_color
|
|
|
|
def animate(
|
|
self,
|
|
attribute: str,
|
|
value: float | Animatable,
|
|
*,
|
|
final_value: object = ...,
|
|
duration: float | None = None,
|
|
speed: float | None = None,
|
|
delay: float = 0.0,
|
|
easing: EasingFunction | str = DEFAULT_EASING,
|
|
on_complete: CallbackType | None = None,
|
|
level: AnimationLevel = "full",
|
|
) -> None:
|
|
"""Animate an attribute.
|
|
|
|
See the guide for how to use the [animation](/guide/animation) system.
|
|
|
|
Args:
|
|
attribute: Name of the attribute to animate.
|
|
value: The value to animate to.
|
|
final_value: The final value of the animation.
|
|
duration: The duration (in seconds) of the animation.
|
|
speed: The speed of the animation.
|
|
delay: A delay (in seconds) before the animation starts.
|
|
easing: An easing method.
|
|
on_complete: A callable to invoke when the animation is finished.
|
|
level: Minimum level required for the animation to take place (inclusive).
|
|
"""
|
|
self._animate(
|
|
attribute,
|
|
value,
|
|
final_value=final_value,
|
|
duration=duration,
|
|
speed=speed,
|
|
delay=delay,
|
|
easing=easing,
|
|
on_complete=on_complete,
|
|
level=level,
|
|
)
|
|
|
|
async def stop_animation(self, attribute: str, complete: bool = True) -> None:
|
|
"""Stop an animation on an attribute.
|
|
|
|
Args:
|
|
attribute: Name of the attribute whose animation should be stopped.
|
|
complete: Should the animation be set to its final value?
|
|
|
|
Note:
|
|
If there is no animation scheduled or running, this is a no-op.
|
|
"""
|
|
await self._animator.stop_animation(self, attribute, complete)
|
|
|
|
@property
|
|
def is_dom_root(self) -> bool:
|
|
"""Is this a root node (i.e. the App)?"""
|
|
return True
|
|
|
|
@property
|
|
def is_attached(self) -> bool:
|
|
"""Is this node linked to the app through the DOM?"""
|
|
return True
|
|
|
|
@property
|
|
def debug(self) -> bool:
|
|
"""Is debug mode enabled?"""
|
|
return "debug" in self.features or constants.DEBUG
|
|
|
|
@property
|
|
def is_headless(self) -> bool:
|
|
"""Is the app running in 'headless' mode?
|
|
|
|
Headless mode is used when running tests with [run_test][textual.app.App.run_test].
|
|
"""
|
|
return False if self._driver is None else self._driver.is_headless
|
|
|
|
@property
|
|
def is_inline(self) -> bool:
|
|
"""Is the app running in 'inline' mode?"""
|
|
return False if self._driver is None else self._driver.is_inline
|
|
|
|
@property
|
|
def is_web(self) -> bool:
|
|
"""Is the app running in 'web' mode via a browser?"""
|
|
return False if self._driver is None else self._driver.is_web
|
|
|
|
@property
|
|
def screen_stack(self) -> list[Screen[Any]]:
|
|
"""A snapshot of the current screen stack.
|
|
|
|
Returns:
|
|
A snapshot of the current state of the screen stack.
|
|
"""
|
|
return self._screen_stacks[self._current_mode].copy()
|
|
|
|
@property
|
|
def _screen_stack(self) -> list[Screen[Any]]:
|
|
"""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]
|
|
|
|
@property
|
|
def current_mode(self) -> str:
|
|
"""The name of the currently active mode."""
|
|
return self._current_mode
|
|
|
|
@property
|
|
def console_options(self) -> ConsoleOptions:
|
|
"""Get options for the Rich console.
|
|
|
|
Returns:
|
|
Console options (same object returned from `console.options`).
|
|
"""
|
|
size = ConsoleDimensions(*self.size)
|
|
console = self.console
|
|
return ConsoleOptions(
|
|
max_height=size.height,
|
|
size=size,
|
|
legacy_windows=console.legacy_windows,
|
|
min_width=1,
|
|
max_width=size.width,
|
|
encoding=console.encoding,
|
|
is_terminal=console.is_terminal,
|
|
)
|
|
|
|
def exit(
|
|
self,
|
|
result: ReturnType | None = None,
|
|
return_code: int = 0,
|
|
message: RenderableType | None = None,
|
|
) -> None:
|
|
"""Exit the app, and return the supplied result.
|
|
|
|
Args:
|
|
result: Return value.
|
|
return_code: The return code. Use non-zero values for error codes.
|
|
message: Optional message to display on exit.
|
|
"""
|
|
self._exit = True
|
|
self._return_value = result
|
|
self._return_code = return_code
|
|
self.post_message(messages.ExitApp())
|
|
if message:
|
|
self._exit_renderables.append(message)
|
|
|
|
@property
|
|
def focused(self) -> Widget | None:
|
|
"""The widget that is focused on the currently active screen, or `None`.
|
|
|
|
Focused widgets receive keyboard input.
|
|
|
|
Returns:
|
|
The currently focused widget, or `None` if nothing is focused.
|
|
"""
|
|
focused = self.screen.focused
|
|
if focused is not None and focused.loading:
|
|
return None
|
|
return focused
|
|
|
|
@property
|
|
def active_bindings(self) -> dict[str, ActiveBinding]:
|
|
"""Get currently active bindings.
|
|
|
|
If no widget is focused, then app-level bindings are returned.
|
|
If a widget is focused, then any bindings present in the active screen and app are merged and returned.
|
|
|
|
This property may be used to inspect current bindings.
|
|
|
|
Returns:
|
|
A dict that maps keys on to binding information.
|
|
"""
|
|
return self.screen.active_bindings
|
|
|
|
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
"""A generator of system commands used in the command palette.
|
|
|
|
Args:
|
|
screen: The screen where the command palette was invoked from.
|
|
|
|
Implement this method in your App subclass if you want to add custom commands.
|
|
Here is an example:
|
|
|
|
```python
|
|
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
yield from super().get_system_commands(screen)
|
|
yield SystemCommand("Bell", "Ring the bell", self.bell)
|
|
```
|
|
|
|
!!! note
|
|
Requires that [`SystemCommandsProvider`][textual.system_commands.SystemCommandsProvider] is in `App.COMMANDS` class variable.
|
|
|
|
Yields:
|
|
[SystemCommand][textual.app.SystemCommand] instances.
|
|
"""
|
|
if not self.ansi_color:
|
|
yield SystemCommand(
|
|
"Theme",
|
|
"Change the current theme",
|
|
self.action_change_theme,
|
|
)
|
|
yield SystemCommand(
|
|
"Quit",
|
|
"Quit the application as soon as possible",
|
|
self.action_quit,
|
|
)
|
|
|
|
if screen.query("HelpPanel"):
|
|
yield SystemCommand(
|
|
"Keys",
|
|
"Hide the keys and widget help panel",
|
|
self.action_hide_help_panel,
|
|
)
|
|
else:
|
|
yield SystemCommand(
|
|
"Keys",
|
|
"Show help for the focused widget and a summary of available keys",
|
|
self.action_show_help_panel,
|
|
)
|
|
|
|
if screen.maximized is not None:
|
|
yield SystemCommand(
|
|
"Minimize",
|
|
"Minimize the widget and restore to normal size",
|
|
screen.action_minimize,
|
|
)
|
|
elif screen.focused is not None and screen.focused.allow_maximize:
|
|
yield SystemCommand(
|
|
"Maximize", "Maximize the focused widget", screen.action_maximize
|
|
)
|
|
|
|
yield SystemCommand(
|
|
"Screenshot",
|
|
"Save an SVG 'screenshot' of the current screen",
|
|
lambda: self.set_timer(0.1, self.deliver_screenshot),
|
|
)
|
|
|
|
def get_default_screen(self) -> Screen:
|
|
"""Get the default screen.
|
|
|
|
This is called when the App is first composed. The returned screen instance
|
|
will be the first screen on the stack.
|
|
|
|
Implement this method if you would like to use a custom Screen as the default screen.
|
|
|
|
Returns:
|
|
A screen instance.
|
|
"""
|
|
return Screen(id="_default")
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Yield child widgets for a container.
|
|
|
|
This method should be implemented in a subclass.
|
|
"""
|
|
yield from ()
|
|
|
|
def get_theme_variable_defaults(self) -> dict[str, str]:
|
|
"""Get the default values for the `variables` used in a theme.
|
|
|
|
If the currently specified theme doesn't define a value for a variable,
|
|
the value specified here will be used as a fallback.
|
|
|
|
If a variable is referenced in CSS but does not appear either here
|
|
or in the theme, the CSS will fail to parse on startup.
|
|
|
|
This method allows applications to define their own variables, beyond
|
|
those offered by Textual, which can then be overridden by a Theme.
|
|
|
|
Returns:
|
|
A mapping of variable name (e.g. "my-button-background-color") to value.
|
|
Values can be any valid CSS value, e.g. "red 50%", "auto 90%",
|
|
"#ff0000", "rgb(255, 0, 0)", etc.
|
|
"""
|
|
return {}
|
|
|
|
def get_css_variables(self) -> dict[str, str]:
|
|
"""Get a mapping of variables used to pre-populate CSS.
|
|
|
|
May be implemented in a subclass to add new CSS variables.
|
|
|
|
Returns:
|
|
A mapping of variable name to value.
|
|
"""
|
|
theme = self.current_theme
|
|
# Build the Textual color system from the theme.
|
|
# This will contain $secondary, $primary, $background, etc.
|
|
variables = theme.to_color_system().generate()
|
|
# Apply the additional variables from the theme
|
|
variables = {**variables, **(theme.variables)}
|
|
theme_variables = self.get_theme_variable_defaults()
|
|
|
|
combined_variables = {**theme_variables, **variables}
|
|
self.theme_variables = combined_variables
|
|
return combined_variables
|
|
|
|
def get_theme(self, theme_name: str) -> Theme | None:
|
|
"""Get a theme by name.
|
|
|
|
Args:
|
|
theme_name: The name of the theme to get. May also be a comma
|
|
separated list of names, to pick the first available theme.
|
|
|
|
Returns:
|
|
A Theme instance and None if the theme doesn't exist.
|
|
"""
|
|
theme_names = [token.strip() for token in theme_name.split(",")]
|
|
for theme_name in theme_names:
|
|
if theme_name in self.available_themes:
|
|
return self.available_themes[theme_name]
|
|
return None
|
|
|
|
def register_theme(self, theme: Theme) -> None:
|
|
"""Register a theme with the app.
|
|
|
|
If the theme already exists, it will be overridden.
|
|
|
|
After registering a theme, you can activate it by setting the
|
|
`App.theme` attribute. To retrieve a registered theme, use the
|
|
`App.get_theme` method.
|
|
|
|
Args:
|
|
theme: The theme to register.
|
|
"""
|
|
self._registered_themes[theme.name] = theme
|
|
|
|
def unregister_theme(self, theme_name: str) -> None:
|
|
"""Unregister a theme with the app.
|
|
|
|
Args:
|
|
theme_name: The name of the theme to unregister.
|
|
"""
|
|
if theme_name in self._registered_themes:
|
|
del self._registered_themes[theme_name]
|
|
|
|
@property
|
|
def available_themes(self) -> dict[str, Theme]:
|
|
"""All available themes (all built-in themes plus any that have been registered).
|
|
|
|
A dictionary mapping theme names to Theme instances.
|
|
"""
|
|
return {**self._registered_themes}
|
|
|
|
@property
|
|
def current_theme(self) -> Theme:
|
|
theme = self.get_theme(self.theme)
|
|
if theme is None:
|
|
theme = self.get_theme("textual-dark")
|
|
assert theme is not None # validated by _validate_theme
|
|
return theme
|
|
|
|
def _validate_theme(self, theme_name: str) -> str:
|
|
if theme_name not in self.available_themes:
|
|
message = (
|
|
f"Theme {theme_name!r} has not been registered. "
|
|
"Call 'App.register_theme' before setting the 'App.theme' attribute."
|
|
)
|
|
raise InvalidThemeError(message)
|
|
return theme_name
|
|
|
|
def _watch_theme(self, theme_name: str) -> None:
|
|
"""Apply a theme to the application.
|
|
|
|
This method is called when the theme reactive attribute is set.
|
|
"""
|
|
theme = self.current_theme
|
|
dark = theme.dark
|
|
self.ansi_color = theme_name == "textual-ansi"
|
|
self.set_class(dark, "-dark-mode", update=False)
|
|
self.set_class(not dark, "-light-mode", update=False)
|
|
self._refresh_truecolor_filter(self.ansi_theme)
|
|
self._invalidate_css()
|
|
self.call_next(partial(self.refresh_css, animate=False))
|
|
self.call_next(self.theme_changed_signal.publish, theme)
|
|
|
|
def _invalidate_css(self) -> None:
|
|
"""Invalidate CSS, so it will be refreshed."""
|
|
self._css_update_count += 1
|
|
|
|
def watch_ansi_theme_dark(self, theme: TerminalTheme) -> None:
|
|
if self.current_theme.dark:
|
|
self._refresh_truecolor_filter(theme)
|
|
self._invalidate_css()
|
|
self.call_next(self.refresh_css)
|
|
|
|
def watch_ansi_theme_light(self, theme: TerminalTheme) -> None:
|
|
if not self.current_theme.dark:
|
|
self._refresh_truecolor_filter(theme)
|
|
self._invalidate_css()
|
|
self.call_next(self.refresh_css)
|
|
|
|
@property
|
|
def ansi_theme(self) -> TerminalTheme:
|
|
"""The ANSI TerminalTheme currently being used.
|
|
|
|
Defines how colors defined as ANSI (e.g. `magenta`) inside Rich renderables
|
|
are mapped to hex codes.
|
|
"""
|
|
return (
|
|
self.ansi_theme_dark if self.current_theme.dark else self.ansi_theme_light
|
|
)
|
|
|
|
def _refresh_truecolor_filter(self, theme: TerminalTheme) -> None:
|
|
"""Update the ANSI to Truecolor filter, if available, with a new theme mapping.
|
|
|
|
Args:
|
|
theme: The new terminal theme to use for mapping ANSI to truecolor.
|
|
"""
|
|
filters = self._filters
|
|
for index, filter in enumerate(filters):
|
|
if isinstance(filter, ANSIToTruecolor):
|
|
filters[index] = ANSIToTruecolor(theme, enabled=not self.ansi_color)
|
|
return
|
|
|
|
def get_driver_class(self) -> Type[Driver]:
|
|
"""Get a driver class for this platform.
|
|
|
|
This method is called by the constructor, and unlikely to be required when
|
|
building a Textual app.
|
|
|
|
Returns:
|
|
A Driver class which manages input and display.
|
|
"""
|
|
|
|
driver_class: Type[Driver]
|
|
|
|
driver_import = constants.DRIVER
|
|
if driver_import is not None:
|
|
# The driver class is set from the environment
|
|
# Syntax should be foo.bar.baz:MyDriver
|
|
module_import, _, driver_symbol = driver_import.partition(":")
|
|
driver_module = importlib.import_module(module_import)
|
|
driver_class = getattr(driver_module, driver_symbol)
|
|
if not inspect.isclass(driver_class) or not issubclass(
|
|
driver_class, Driver
|
|
):
|
|
raise RuntimeError(
|
|
f"Unable to import {driver_import!r}; {driver_class!r} is not a Driver class "
|
|
)
|
|
return driver_class
|
|
|
|
if WINDOWS:
|
|
from textual.drivers.windows_driver import WindowsDriver
|
|
|
|
driver_class = WindowsDriver
|
|
else:
|
|
from textual.drivers.linux_driver import LinuxDriver
|
|
|
|
driver_class = LinuxDriver
|
|
return driver_class
|
|
|
|
def __rich_repr__(self) -> rich.repr.Result:
|
|
yield "title", self.title
|
|
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)
|
|
|
|
@property
|
|
def animator(self) -> Animator:
|
|
"""The animator object."""
|
|
return self._animator
|
|
|
|
@property
|
|
def screen(self) -> Screen[object]:
|
|
"""The current active screen.
|
|
|
|
Returns:
|
|
The currently active (visible) screen.
|
|
|
|
Raises:
|
|
ScreenStackError: If there are no screens on the stack.
|
|
"""
|
|
try:
|
|
return self._screen_stack[-1]
|
|
except KeyError:
|
|
raise UnknownModeError(f"No known mode {self._current_mode!r}") from None
|
|
except IndexError:
|
|
raise ScreenStackError("No screens on stack") from None
|
|
|
|
@property
|
|
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]):
|
|
screens.append(screen)
|
|
if screen.styles.background.a == 1:
|
|
break
|
|
background_screens = screens[::-1]
|
|
return background_screens
|
|
|
|
@property
|
|
def size(self) -> Size:
|
|
"""The size of the terminal.
|
|
|
|
Returns:
|
|
Size of the terminal.
|
|
"""
|
|
if self._size is not None:
|
|
return self._size
|
|
if self._driver is not None and self._driver._size is not None:
|
|
width, height = self._driver._size
|
|
else:
|
|
width, height = self.console.size
|
|
return Size(width, height)
|
|
|
|
@property
|
|
def viewport_size(self) -> Size:
|
|
"""Get the viewport size (size of the screen)."""
|
|
try:
|
|
return self.screen.size
|
|
except (ScreenStackError, NoScreen):
|
|
return self.size
|
|
|
|
def _get_inline_height(self) -> int:
|
|
"""Get the inline height (height when in inline mode).
|
|
|
|
Returns:
|
|
Height in lines.
|
|
"""
|
|
size = self.size
|
|
return max(screen._get_inline_height(size) for screen in self._screen_stack)
|
|
|
|
@property
|
|
def log(self) -> Logger:
|
|
"""The textual logger.
|
|
|
|
Example:
|
|
```python
|
|
self.log("Hello, World!")
|
|
self.log(self.tree)
|
|
```
|
|
|
|
Returns:
|
|
A Textual logger.
|
|
"""
|
|
return self._logger
|
|
|
|
def _log(
|
|
self,
|
|
group: LogGroup,
|
|
verbosity: LogVerbosity,
|
|
_textual_calling_frame: inspect.Traceback,
|
|
*objects: Any,
|
|
**kwargs,
|
|
) -> None:
|
|
"""Write to logs or devtools.
|
|
|
|
Positional args will be logged. Keyword args will be prefixed with the key.
|
|
|
|
Example:
|
|
```python
|
|
data = [1,2,3]
|
|
self.log("Hello, World", state=data)
|
|
self.log(self.tree)
|
|
self.log(locals())
|
|
```
|
|
|
|
Args:
|
|
verbosity: Verbosity level 0-3.
|
|
"""
|
|
|
|
devtools = self.devtools
|
|
if devtools is None or not devtools.is_connected:
|
|
return
|
|
|
|
if verbosity.value > LogVerbosity.NORMAL.value and not devtools.verbose:
|
|
return
|
|
|
|
try:
|
|
from textual_dev.client import DevtoolsLog
|
|
|
|
if len(objects) == 1 and not kwargs:
|
|
devtools.log(
|
|
DevtoolsLog(objects, caller=_textual_calling_frame),
|
|
group,
|
|
verbosity,
|
|
)
|
|
else:
|
|
output = " ".join(str(arg) for arg in objects)
|
|
if kwargs:
|
|
key_values = " ".join(
|
|
f"{key}={value!r}" for key, value in kwargs.items()
|
|
)
|
|
output = f"{output} {key_values}" if output else key_values
|
|
devtools.log(
|
|
DevtoolsLog(output, caller=_textual_calling_frame),
|
|
group,
|
|
verbosity,
|
|
)
|
|
except Exception as error:
|
|
self._handle_exception(error)
|
|
|
|
def get_loading_widget(self) -> Widget:
|
|
"""Get a widget to be used as a loading indicator.
|
|
|
|
Extend this method if you want to display the loading state a little differently.
|
|
|
|
Returns:
|
|
A widget to display a loading state.
|
|
"""
|
|
from textual.widgets import LoadingIndicator
|
|
|
|
return LoadingIndicator()
|
|
|
|
def copy_to_clipboard(self, text: str) -> None:
|
|
"""Copy text to the clipboard.
|
|
|
|
!!! note
|
|
|
|
This does not work on macOS Terminal, but will work on most other terminals.
|
|
|
|
Args:
|
|
text: Text you wish to copy to the clipboard.
|
|
"""
|
|
self._clipboard = text
|
|
if self._driver is None:
|
|
return
|
|
import base64
|
|
|
|
base64_text = base64.b64encode(text.encode("utf-8")).decode("utf-8")
|
|
self._driver.write(f"\x1b]52;c;{base64_text}\a")
|
|
|
|
def call_from_thread(
|
|
self,
|
|
callback: Callable[..., CallThreadReturnType | Awaitable[CallThreadReturnType]],
|
|
*args: Any,
|
|
**kwargs: Any,
|
|
) -> CallThreadReturnType:
|
|
"""Run a callable from another thread, and return the result.
|
|
|
|
Like asyncio apps in general, Textual apps are not thread-safe. If you call methods
|
|
or set attributes on Textual objects from a thread, you may get unpredictable results.
|
|
|
|
This method will ensure that your code runs within the correct context.
|
|
|
|
!!! tip
|
|
|
|
Consider using [post_message][textual.message_pump.MessagePump.post_message] which is also thread-safe.
|
|
|
|
Args:
|
|
callback: A callable to run.
|
|
*args: Arguments to the callback.
|
|
**kwargs: Keyword arguments for the callback.
|
|
|
|
Raises:
|
|
RuntimeError: If the app isn't running or if this method is called from the same
|
|
thread where the app is running.
|
|
|
|
Returns:
|
|
The result of the callback.
|
|
"""
|
|
|
|
if self._loop is None:
|
|
raise RuntimeError("App is not running")
|
|
|
|
if self._thread_id == threading.get_ident():
|
|
raise RuntimeError(
|
|
"The `call_from_thread` method must run in a different thread from the app"
|
|
)
|
|
|
|
callback_with_args = partial(callback, *args, **kwargs)
|
|
|
|
async def run_callback() -> CallThreadReturnType:
|
|
"""Run the callback, set the result or error on the future."""
|
|
with self._context():
|
|
return await invoke(callback_with_args)
|
|
|
|
# Post the message to the main loop
|
|
future: Future[CallThreadReturnType] = asyncio.run_coroutine_threadsafe(
|
|
run_callback(), loop=self._loop
|
|
)
|
|
result = future.result()
|
|
return result
|
|
|
|
def action_change_theme(self) -> None:
|
|
"""An [action](/guide/actions) to change the current theme."""
|
|
self.search_themes()
|
|
|
|
def action_screenshot(
|
|
self, filename: str | None = None, path: str | None = None
|
|
) -> None:
|
|
"""This [action](/guide/actions) will save an SVG file containing the current contents of the screen.
|
|
|
|
Args:
|
|
filename: Filename of screenshot, or None to auto-generate.
|
|
path: Path to directory. Defaults to the user's Downloads directory.
|
|
"""
|
|
self.deliver_screenshot(filename, path)
|
|
|
|
def export_screenshot(
|
|
self,
|
|
*,
|
|
title: str | None = None,
|
|
simplify: bool = False,
|
|
) -> str:
|
|
"""Export an SVG screenshot of the current screen.
|
|
|
|
See also [save_screenshot][textual.app.App.save_screenshot] which writes the screenshot to a file.
|
|
|
|
Args:
|
|
title: The title of the exported screenshot or None
|
|
to use app title.
|
|
simplify: Simplify the segments by combining contiguous segments with the same style.
|
|
"""
|
|
assert self._driver is not None, "App must be running"
|
|
width, height = self.size
|
|
|
|
console = Console(
|
|
width=width,
|
|
height=height,
|
|
file=io.StringIO(),
|
|
force_terminal=True,
|
|
color_system="truecolor",
|
|
record=True,
|
|
legacy_windows=False,
|
|
safe_box=False,
|
|
)
|
|
screen_render = self.screen._compositor.render_update(
|
|
full=True, screen_stack=self.app._background_screens, simplify=simplify
|
|
)
|
|
console.print(screen_render)
|
|
return console.export_svg(title=title or self.title)
|
|
|
|
def save_screenshot(
|
|
self,
|
|
filename: str | None = None,
|
|
path: str | None = None,
|
|
time_format: str | None = None,
|
|
) -> str:
|
|
"""Save an SVG screenshot of the current screen.
|
|
|
|
Args:
|
|
filename: Filename of SVG screenshot, or None to auto-generate
|
|
a filename with the date and time.
|
|
path: Path to directory for output. Defaults to current working directory.
|
|
time_format: Date and time format to use if filename is None.
|
|
Defaults to a format like ISO 8601 with some reserved characters replaced with underscores.
|
|
|
|
Returns:
|
|
Filename of screenshot.
|
|
"""
|
|
path = path or "./"
|
|
if not filename:
|
|
svg_filename = generate_datetime_filename(self.title, ".svg", time_format)
|
|
else:
|
|
svg_filename = filename
|
|
svg_path = os.path.expanduser(os.path.join(path, svg_filename))
|
|
screenshot_svg = self.export_screenshot()
|
|
with open(svg_path, "w", encoding="utf-8") as svg_file:
|
|
svg_file.write(screenshot_svg)
|
|
return svg_path
|
|
|
|
def deliver_screenshot(
|
|
self,
|
|
filename: str | None = None,
|
|
path: str | None = None,
|
|
time_format: str | None = None,
|
|
) -> str | None:
|
|
"""Deliver a screenshot of the app.
|
|
|
|
This will save the screenshot when running locally, or serve it when the app
|
|
is running in a web browser.
|
|
|
|
Args:
|
|
filename: Filename of SVG screenshot, or None to auto-generate
|
|
a filename with the date and time.
|
|
path: Path to directory for output when saving locally (not used when app is running in the browser).
|
|
Defaults to current working directory.
|
|
time_format: Date and time format to use if filename is None.
|
|
Defaults to a format like ISO 8601 with some reserved characters replaced with underscores.
|
|
|
|
Returns:
|
|
The delivery key that uniquely identifies the file delivery.
|
|
"""
|
|
if not filename:
|
|
svg_filename = generate_datetime_filename(self.title, ".svg", time_format)
|
|
else:
|
|
svg_filename = filename
|
|
screenshot_svg = self.export_screenshot()
|
|
return self.deliver_text(
|
|
io.StringIO(screenshot_svg),
|
|
save_directory=path,
|
|
save_filename=svg_filename,
|
|
open_method="browser",
|
|
mime_type="image/svg+xml",
|
|
name="screenshot",
|
|
)
|
|
|
|
def search_commands(
|
|
self,
|
|
commands: Sequence[CommandListItem],
|
|
placeholder: str = "Search for commands…",
|
|
) -> AwaitMount:
|
|
"""Show a list of commands in the app.
|
|
|
|
Args:
|
|
commands: A list of SimpleCommand instances.
|
|
placeholder: Placeholder text for the search field.
|
|
|
|
Returns:
|
|
AwaitMount: An awaitable that resolves when the commands are shown.
|
|
"""
|
|
return self.push_screen(
|
|
CommandPalette(
|
|
providers=[SimpleProvider(self.screen, commands)],
|
|
placeholder=placeholder,
|
|
)
|
|
)
|
|
|
|
def search_themes(self) -> None:
|
|
"""Show a fuzzy search command palette containing all registered themes.
|
|
|
|
Selecting a theme in the list will change the app's theme.
|
|
"""
|
|
self.push_screen(
|
|
CommandPalette(
|
|
providers=[ThemeProvider],
|
|
placeholder="Search for themes…",
|
|
),
|
|
)
|
|
|
|
def bind(
|
|
self,
|
|
keys: str,
|
|
action: str,
|
|
*,
|
|
description: str = "",
|
|
show: bool = True,
|
|
key_display: str | None = None,
|
|
) -> None:
|
|
"""Bind a key to an action.
|
|
|
|
!!! warning
|
|
This method may be private or removed in a future version of Textual.
|
|
See [dynamic actions](/guide/actions#dynamic-actions) for a more flexible alternative to updating bindings.
|
|
|
|
Args:
|
|
keys: A comma separated list of keys, i.e.
|
|
action: Action to bind to.
|
|
description: Short description of action.
|
|
show: Show key in UI.
|
|
key_display: Replacement text for key, or None to use default.
|
|
"""
|
|
self._bindings.bind(
|
|
keys, action, description, show=show, key_display=key_display
|
|
)
|
|
|
|
def get_key_display(self, binding: Binding) -> str:
|
|
"""Format a bound key for display in footer / key panel etc.
|
|
|
|
!!! note
|
|
You can implement this in a subclass if you want to change how keys are displayed in your app.
|
|
|
|
Args:
|
|
binding: A Binding.
|
|
|
|
Returns:
|
|
A string used to represent the key.
|
|
"""
|
|
# Dev has overridden the key display, so use that
|
|
if binding.key_display:
|
|
return binding.key_display
|
|
|
|
# Extract modifiers
|
|
modifiers, key = binding.parse_key()
|
|
|
|
# Format the key (replace unicode names with character)
|
|
key = format_key(key)
|
|
|
|
# Convert ctrl modifier to caret
|
|
if "ctrl" in modifiers:
|
|
modifiers.pop(modifiers.index("ctrl"))
|
|
key = f"^{key}"
|
|
# Join everything with +
|
|
key_tokens = modifiers + [key]
|
|
return "+".join(key_tokens)
|
|
|
|
async def _press_keys(self, keys: Iterable[str]) -> None:
|
|
"""A task to send key events."""
|
|
import unicodedata
|
|
|
|
app = self
|
|
driver = app._driver
|
|
assert driver is not None
|
|
for key in keys:
|
|
if key.startswith("wait:"):
|
|
_, wait_ms = key.split(":")
|
|
await asyncio.sleep(float(wait_ms) / 1000)
|
|
await app._animator.wait_until_complete()
|
|
else:
|
|
if len(key) == 1 and not key.isalnum():
|
|
key = _character_to_key(key)
|
|
original_key = REPLACED_KEYS.get(key, key)
|
|
char: str | None
|
|
try:
|
|
char = unicodedata.lookup(_get_unicode_name_from_key(original_key))
|
|
except KeyError:
|
|
char = key if len(key) == 1 else None
|
|
key_event = events.Key(key, char)
|
|
key_event.set_sender(app)
|
|
driver.send_message(key_event)
|
|
await wait_for_idle(0)
|
|
await app._animator.wait_until_complete()
|
|
await wait_for_idle(0)
|
|
|
|
def _flush(self, stderr: bool = False) -> None:
|
|
"""Called when stdout or stderr is flushed.
|
|
|
|
Args:
|
|
stderr: True if the print was to stderr, or False for stdout.
|
|
|
|
"""
|
|
if self._devtools_redirector is not None:
|
|
self._devtools_redirector.flush()
|
|
|
|
def _print(self, text: str, stderr: bool = False) -> None:
|
|
"""Called with captured print.
|
|
|
|
Dispatches printed content to appropriate destinations: devtools,
|
|
widgets currently capturing output, stdout/stderr.
|
|
|
|
Args:
|
|
text: Text that has been printed.
|
|
stderr: True if the print was to stderr, or False for stdout.
|
|
"""
|
|
if self._devtools_redirector is not None:
|
|
current_frame = inspect.currentframe()
|
|
self._devtools_redirector.write(
|
|
text, current_frame.f_back if current_frame is not None else None
|
|
)
|
|
|
|
# If we're in headless mode, we want printed text to still reach stdout/stderr.
|
|
if self.is_headless:
|
|
target_stream = self._original_stderr if stderr else self._original_stdout
|
|
target_stream.write(text)
|
|
|
|
# Send Print events to all widgets that are currently capturing output.
|
|
for target, (_stdout, _stderr) in self._capture_print.items():
|
|
if (_stderr and stderr) or (_stdout and not stderr):
|
|
target.post_message(events.Print(text, stderr=stderr))
|
|
|
|
def begin_capture_print(
|
|
self, target: MessageTarget, stdout: bool = True, stderr: bool = True
|
|
) -> None:
|
|
"""Capture content that is printed (or written to stdout / stderr).
|
|
|
|
If printing is captured, the `target` will be sent an [events.Print][textual.events.Print] message.
|
|
|
|
Args:
|
|
target: The widget where print content will be sent.
|
|
stdout: Capture stdout.
|
|
stderr: Capture stderr.
|
|
"""
|
|
if not stdout and not stderr:
|
|
self.end_capture_print(target)
|
|
else:
|
|
self._capture_print[target] = (stdout, stderr)
|
|
|
|
def end_capture_print(self, target: MessageTarget) -> None:
|
|
"""End capturing of prints.
|
|
|
|
Args:
|
|
target: The widget that was capturing prints.
|
|
"""
|
|
self._capture_print.pop(target)
|
|
|
|
@asynccontextmanager
|
|
async def run_test(
|
|
self,
|
|
*,
|
|
headless: bool = True,
|
|
size: tuple[int, int] | None = (80, 24),
|
|
tooltips: bool = False,
|
|
notifications: bool = False,
|
|
message_hook: Callable[[Message], None] | None = None,
|
|
) -> AsyncGenerator[Pilot[ReturnType], None]:
|
|
"""An asynchronous context manager for testing apps.
|
|
|
|
!!! tip
|
|
|
|
See the guide for [testing](/guide/testing) Textual apps.
|
|
|
|
Use this to run your app in "headless" mode (no output) and drive the app via a [Pilot][textual.pilot.Pilot] object.
|
|
|
|
Example:
|
|
|
|
```python
|
|
async with app.run_test() as pilot:
|
|
await pilot.click("#Button.ok")
|
|
assert ...
|
|
```
|
|
|
|
Args:
|
|
headless: Run in headless mode (no output or input).
|
|
size: Force terminal size to `(WIDTH, HEIGHT)`,
|
|
or None to auto-detect.
|
|
tooltips: Enable tooltips when testing.
|
|
notifications: Enable notifications when testing.
|
|
message_hook: An optional callback that will be called each time any message arrives at any
|
|
message pump in the app.
|
|
"""
|
|
from textual.pilot import Pilot
|
|
|
|
app = self
|
|
app._disable_tooltips = not tooltips
|
|
app._disable_notifications = not notifications
|
|
app_ready_event = asyncio.Event()
|
|
|
|
def on_app_ready() -> None:
|
|
"""Called when app is ready to process events."""
|
|
app_ready_event.set()
|
|
|
|
async def run_app(app: App[ReturnType]) -> None:
|
|
"""Run the apps message loop.
|
|
|
|
Args:
|
|
app: App to run.
|
|
"""
|
|
|
|
with app._context():
|
|
try:
|
|
if message_hook is not None:
|
|
message_hook_context_var.set(message_hook)
|
|
app._loop = asyncio.get_running_loop()
|
|
app._thread_id = threading.get_ident()
|
|
await app._process_messages(
|
|
ready_callback=on_app_ready,
|
|
headless=headless,
|
|
terminal_size=size,
|
|
)
|
|
finally:
|
|
app_ready_event.set()
|
|
|
|
# Launch the app in the "background"
|
|
|
|
self._task = app_task = create_task(run_app(app), name=f"run_test {app}")
|
|
|
|
# Wait until the app has performed all startup routines.
|
|
await app_ready_event.wait()
|
|
with app._context():
|
|
# Context manager returns pilot object to manipulate the app
|
|
try:
|
|
pilot = Pilot(app)
|
|
await pilot._wait_for_screen()
|
|
yield pilot
|
|
finally:
|
|
await asyncio.sleep(0)
|
|
# Shutdown the app cleanly
|
|
await app._shutdown()
|
|
await app_task
|
|
# Re-raise the exception which caused panic so test frameworks are aware
|
|
if self._exception:
|
|
raise self._exception
|
|
|
|
async def run_async(
|
|
self,
|
|
*,
|
|
headless: bool = False,
|
|
inline: bool = False,
|
|
inline_no_clear: bool = False,
|
|
mouse: bool = True,
|
|
size: tuple[int, int] | None = None,
|
|
auto_pilot: AutopilotCallbackType | None = None,
|
|
) -> ReturnType | None:
|
|
"""Run the app asynchronously.
|
|
|
|
Args:
|
|
headless: Run in headless mode (no output).
|
|
inline: Run the app inline (under the prompt).
|
|
inline_no_clear: Don't clear the app output when exiting an inline app.
|
|
mouse: Enable mouse support.
|
|
size: Force terminal size to `(WIDTH, HEIGHT)`,
|
|
or None to auto-detect.
|
|
auto_pilot: An autopilot coroutine.
|
|
|
|
Returns:
|
|
App return value.
|
|
"""
|
|
from textual.pilot import Pilot
|
|
|
|
app = self
|
|
auto_pilot_task: Task | None = None
|
|
|
|
if auto_pilot is None and constants.PRESS:
|
|
keys = constants.PRESS.split(",")
|
|
|
|
async def press_keys(pilot: Pilot[ReturnType]) -> None:
|
|
"""Auto press keys."""
|
|
await pilot.press(*keys)
|
|
|
|
auto_pilot = press_keys
|
|
|
|
async def app_ready() -> None:
|
|
"""Called by the message loop when the app is ready."""
|
|
nonlocal auto_pilot_task
|
|
|
|
if auto_pilot is not None:
|
|
|
|
async def run_auto_pilot(
|
|
auto_pilot: AutopilotCallbackType, pilot: Pilot
|
|
) -> None:
|
|
with self._context():
|
|
try:
|
|
await auto_pilot(pilot)
|
|
except Exception:
|
|
app.exit()
|
|
raise
|
|
|
|
pilot = Pilot(app)
|
|
auto_pilot_task = create_task(
|
|
run_auto_pilot(auto_pilot, pilot), name=repr(pilot)
|
|
)
|
|
|
|
self._thread_init()
|
|
|
|
loop = app._loop = asyncio.get_running_loop()
|
|
if hasattr(asyncio, "eager_task_factory"):
|
|
loop.set_task_factory(asyncio.eager_task_factory)
|
|
with app._context():
|
|
try:
|
|
await app._process_messages(
|
|
ready_callback=None if auto_pilot is None else app_ready,
|
|
headless=headless,
|
|
inline=inline,
|
|
inline_no_clear=inline_no_clear,
|
|
mouse=mouse,
|
|
terminal_size=size,
|
|
)
|
|
finally:
|
|
try:
|
|
if auto_pilot_task is not None:
|
|
await auto_pilot_task
|
|
finally:
|
|
try:
|
|
await asyncio.shield(app._shutdown())
|
|
except asyncio.CancelledError:
|
|
pass
|
|
app._loop = None
|
|
app._thread_id = 0
|
|
|
|
return app.return_value
|
|
|
|
def run(
|
|
self,
|
|
*,
|
|
headless: bool = False,
|
|
inline: bool = False,
|
|
inline_no_clear: bool = False,
|
|
mouse: bool = True,
|
|
size: tuple[int, int] | None = None,
|
|
auto_pilot: AutopilotCallbackType | None = None,
|
|
loop: AbstractEventLoop | None = None,
|
|
) -> ReturnType | None:
|
|
"""Run the app.
|
|
|
|
Args:
|
|
headless: Run in headless mode (no output).
|
|
inline: Run the app inline (under the prompt).
|
|
inline_no_clear: Don't clear the app output when exiting an inline app.
|
|
mouse: Enable mouse support.
|
|
size: Force terminal size to `(WIDTH, HEIGHT)`,
|
|
or None to auto-detect.
|
|
auto_pilot: An auto pilot coroutine.
|
|
loop: Asyncio loop instance, or `None` to use default.
|
|
Returns:
|
|
App return value.
|
|
"""
|
|
|
|
async def run_app() -> ReturnType | None:
|
|
"""Run the app."""
|
|
return await self.run_async(
|
|
headless=headless,
|
|
inline=inline,
|
|
inline_no_clear=inline_no_clear,
|
|
mouse=mouse,
|
|
size=size,
|
|
auto_pilot=auto_pilot,
|
|
)
|
|
|
|
if loop is None:
|
|
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
|
|
# N.B. This does work with Python<3.10, but global Locks, Events, etc
|
|
# eagerly bind the event loop, and result in Future bound to wrong
|
|
# loop errors.
|
|
return asyncio.run(run_app())
|
|
try:
|
|
global_loop = asyncio.get_event_loop()
|
|
except RuntimeError:
|
|
# the global event loop may have been destroyed by someone running
|
|
# asyncio.run(), or asyncio.set_event_loop(None), in which case
|
|
# we need to use asyncio.run() also. (We run this outside the
|
|
# context of an exception handler)
|
|
pass
|
|
else:
|
|
return global_loop.run_until_complete(run_app())
|
|
return asyncio.run(run_app())
|
|
return loop.run_until_complete(run_app())
|
|
|
|
async def _on_css_change(self) -> None:
|
|
"""Callback for the file monitor, called when CSS files change."""
|
|
css_paths = (
|
|
self.css_monitor._paths if self.css_monitor is not None else self.css_path
|
|
)
|
|
if css_paths:
|
|
try:
|
|
time = perf_counter()
|
|
stylesheet = self.stylesheet.copy()
|
|
try:
|
|
stylesheet.read_all(css_paths)
|
|
except StylesheetError as error:
|
|
# If one of the CSS paths is no longer available (or perhaps temporarily unavailable),
|
|
# we'll end up with partial CSS, which is probably confusing more than anything. We opt to do
|
|
# nothing here, knowing that we'll retry again very soon, on the next file monitor invocation.
|
|
# Related issue: https://github.com/Textualize/textual/issues/3996
|
|
self.log.warning(str(error))
|
|
return
|
|
stylesheet.parse()
|
|
elapsed = (perf_counter() - time) * 1000
|
|
if self._css_has_errors:
|
|
from rich.panel import Panel
|
|
|
|
self.log.system(
|
|
Panel(
|
|
"CSS files successfully loaded after previous error:\n\n- "
|
|
+ "\n- ".join(str(path) for path in css_paths),
|
|
style="green",
|
|
border_style="green",
|
|
)
|
|
)
|
|
self.log.system(
|
|
f"<stylesheet> loaded {len(css_paths)} CSS files in {elapsed:.0f} ms"
|
|
)
|
|
except Exception as error:
|
|
# TODO: Catch specific exceptions
|
|
self._css_has_errors = True
|
|
self.log.error(error)
|
|
self.bell()
|
|
else:
|
|
self._css_has_errors = False
|
|
self.stylesheet = stylesheet
|
|
self.stylesheet.update(self)
|
|
for screen in self.screen_stack:
|
|
self.stylesheet.update(screen)
|
|
|
|
def render(self) -> RenderResult:
|
|
"""Render method, inherited from widget, to render the screen's background.
|
|
|
|
May be overridden to customize background visuals.
|
|
|
|
"""
|
|
return Blank(self.styles.background)
|
|
|
|
ExpectType = TypeVar("ExpectType", bound=Widget)
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
@overload
|
|
def get_child_by_id(self, id: str) -> Widget: ...
|
|
|
|
@overload
|
|
def get_child_by_id(
|
|
self, id: str, expect_type: type[ExpectType]
|
|
) -> ExpectType: ...
|
|
|
|
def get_child_by_id(
|
|
self, id: str, expect_type: type[ExpectType] | None = None
|
|
) -> ExpectType | Widget:
|
|
"""Get the first child (immediate descendant) of this DOMNode with the given ID.
|
|
|
|
Args:
|
|
id: The ID of the node to search for.
|
|
expect_type: Require the object be of the supplied type,
|
|
or use `None` to apply no type restriction.
|
|
|
|
Returns:
|
|
The first child of this node with the specified ID.
|
|
|
|
Raises:
|
|
NoMatches: If no children could be found for this ID.
|
|
WrongType: If the wrong type was found.
|
|
"""
|
|
return (
|
|
self.screen.get_child_by_id(id)
|
|
if expect_type is None
|
|
else self.screen.get_child_by_id(id, expect_type)
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
@overload
|
|
def get_widget_by_id(self, id: str) -> Widget: ...
|
|
|
|
@overload
|
|
def get_widget_by_id(
|
|
self, id: str, expect_type: type[ExpectType]
|
|
) -> ExpectType: ...
|
|
|
|
def get_widget_by_id(
|
|
self, id: str, expect_type: type[ExpectType] | None = None
|
|
) -> ExpectType | Widget:
|
|
"""Get the first descendant widget with the given ID.
|
|
|
|
Performs a breadth-first search rooted at the current screen.
|
|
It will not return the Screen if that matches the ID.
|
|
To get the screen, use `self.screen`.
|
|
|
|
Args:
|
|
id: The ID to search for in the subtree
|
|
expect_type: Require the object be of the supplied type, or None for any type.
|
|
Defaults to None.
|
|
|
|
Returns:
|
|
The first descendant encountered with this ID.
|
|
|
|
Raises:
|
|
NoMatches: if no children could be found for this ID
|
|
WrongType: if the wrong type was found.
|
|
"""
|
|
return (
|
|
self.screen.get_widget_by_id(id)
|
|
if expect_type is None
|
|
else self.screen.get_widget_by_id(id, expect_type)
|
|
)
|
|
|
|
def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType:
|
|
"""Get a child of a give type.
|
|
|
|
Args:
|
|
expect_type: The type of the expected child.
|
|
|
|
Raises:
|
|
NoMatches: If no valid child is found.
|
|
|
|
Returns:
|
|
A widget.
|
|
"""
|
|
return self.screen.get_child_by_type(expect_type)
|
|
|
|
def update_styles(self, node: DOMNode) -> None:
|
|
"""Immediately update the styles of this node and all descendant nodes.
|
|
|
|
Should be called whenever CSS classes / pseudo classes change.
|
|
For example, when you hover over a button, the :hover pseudo class
|
|
will be added, and this method is called to apply the corresponding
|
|
:hover styles.
|
|
"""
|
|
descendants = node.walk_children(with_self=True)
|
|
self.stylesheet.update_nodes(descendants, animate=True)
|
|
|
|
def mount(
|
|
self,
|
|
*widgets: Widget,
|
|
before: int | str | Widget | None = None,
|
|
after: int | str | Widget | None = None,
|
|
) -> AwaitMount:
|
|
"""Mount the given widgets relative to the app's screen.
|
|
|
|
Args:
|
|
*widgets: The widget(s) to mount.
|
|
before: Optional location to mount before. An `int` is the index
|
|
of the child to mount before, a `str` is a `query_one` query to
|
|
find the widget to mount before.
|
|
after: Optional location to mount after. An `int` is the index
|
|
of the child to mount after, a `str` is a `query_one` query to
|
|
find the widget to mount after.
|
|
|
|
Returns:
|
|
An awaitable object that waits for widgets to be mounted.
|
|
|
|
Raises:
|
|
MountError: If there is a problem with the mount request.
|
|
|
|
Note:
|
|
Only one of `before` or `after` can be provided. If both are
|
|
provided a `MountError` will be raised.
|
|
"""
|
|
return self.screen.mount(*widgets, before=before, after=after)
|
|
|
|
def mount_all(
|
|
self,
|
|
widgets: Iterable[Widget],
|
|
*,
|
|
before: int | str | Widget | None = None,
|
|
after: int | str | Widget | None = None,
|
|
) -> AwaitMount:
|
|
"""Mount widgets from an iterable.
|
|
|
|
Args:
|
|
widgets: An iterable of widgets.
|
|
before: Optional location to mount before. An `int` is the index
|
|
of the child to mount before, a `str` is a `query_one` query to
|
|
find the widget to mount before.
|
|
after: Optional location to mount after. An `int` is the index
|
|
of the child to mount after, a `str` is a `query_one` query to
|
|
find the widget to mount after.
|
|
|
|
Returns:
|
|
An awaitable object that waits for widgets to be mounted.
|
|
|
|
Raises:
|
|
MountError: If there is a problem with the mount request.
|
|
|
|
Note:
|
|
Only one of `before` or `after` can be provided. If both are
|
|
provided a `MountError` will be raised.
|
|
"""
|
|
return self.mount(*widgets, before=before, after=after)
|
|
|
|
def _init_mode(self, mode: str) -> AwaitMount:
|
|
"""Do internal initialization of a new screen stack mode.
|
|
|
|
Args:
|
|
mode: Name of the mode.
|
|
|
|
Returns:
|
|
An optionally awaitable object which can be awaited until the screen
|
|
associated with the mode has been mounted.
|
|
"""
|
|
|
|
stack = self._screen_stacks.get(mode, [])
|
|
if stack:
|
|
# Mode already exists
|
|
# Return an dummy await
|
|
return AwaitMount(stack[0], [])
|
|
|
|
if mode in self._modes:
|
|
# Mode is defined in MODES
|
|
_screen = self._modes[mode]
|
|
if isinstance(_screen, Screen):
|
|
raise TypeError(
|
|
"MODES cannot contain instances, use a type instead "
|
|
f"(got instance of {type(_screen).__name__} for {mode!r})"
|
|
)
|
|
new_screen: Screen | str = _screen() if callable(_screen) else _screen
|
|
screen, await_mount = self._get_screen(new_screen)
|
|
stack.append(screen)
|
|
self._load_screen_css(screen)
|
|
if screen._css_update_count != self._css_update_count:
|
|
self.refresh_css()
|
|
|
|
screen.post_message(events.ScreenResume())
|
|
else:
|
|
# Mode is not defined
|
|
screen = self.get_default_screen()
|
|
stack.append(screen)
|
|
self._register(self, screen)
|
|
screen.post_message(events.ScreenResume())
|
|
await_mount = AwaitMount(stack[0], [])
|
|
|
|
screen._screen_resized(self.size)
|
|
|
|
self._screen_stacks[mode] = stack
|
|
return await_mount
|
|
|
|
def switch_mode(self, mode: str) -> AwaitMount:
|
|
"""Switch to a given mode.
|
|
|
|
Args:
|
|
mode: The mode to switch to.
|
|
|
|
Returns:
|
|
An optionally awaitable object which waits for the screen associated
|
|
with the mode to be mounted.
|
|
|
|
Raises:
|
|
UnknownModeError: If trying to switch to an unknown mode.
|
|
|
|
"""
|
|
|
|
if mode == self._current_mode:
|
|
return AwaitMount(self.screen, [])
|
|
|
|
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:
|
|
await_mount = self._init_mode(mode)
|
|
else:
|
|
await_mount = AwaitMount(self.screen, [])
|
|
|
|
self._current_mode = mode
|
|
if self.screen._css_update_count != self._css_update_count:
|
|
self.refresh_css()
|
|
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")
|
|
|
|
return await_mount
|
|
|
|
def add_mode(self, mode: str, base_screen: str | 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}.")
|
|
|
|
if isinstance(base_screen, Screen):
|
|
raise TypeError(
|
|
"add_mode() must be called with a Screen type, not an instance"
|
|
f" (got instance of {type(base_screen).__name__})"
|
|
)
|
|
self._modes[mode] = base_screen
|
|
|
|
def remove_mode(self, mode: str) -> AwaitComplete:
|
|
"""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 AwaitComplete.nothing()
|
|
|
|
stack = self._screen_stacks[mode]
|
|
del self._screen_stacks[mode]
|
|
|
|
async def remove_screens() -> None:
|
|
"""Remove screens."""
|
|
for screen in reversed(stack):
|
|
await self._replace_screen(screen)
|
|
|
|
return AwaitComplete(remove_screens()).call_next(self)
|
|
|
|
def is_screen_installed(self, screen: Screen | str) -> bool:
|
|
"""Check if a given screen has been installed.
|
|
|
|
Args:
|
|
screen: Either a Screen object or screen name (the `name` argument when installed).
|
|
|
|
Returns:
|
|
True if the screen is currently installed,
|
|
"""
|
|
if isinstance(screen, str):
|
|
return screen in self._installed_screens
|
|
else:
|
|
return screen in self._installed_screens.values()
|
|
|
|
@overload
|
|
def get_screen(self, screen: ScreenType) -> ScreenType: ...
|
|
|
|
@overload
|
|
def get_screen(self, screen: str) -> Screen: ...
|
|
|
|
@overload
|
|
def get_screen(
|
|
self, screen: str, screen_class: Type[ScreenType] | None = None
|
|
) -> ScreenType: ...
|
|
|
|
@overload
|
|
def get_screen(
|
|
self, screen: ScreenType, screen_class: Type[ScreenType] | None = None
|
|
) -> ScreenType: ...
|
|
|
|
def get_screen(
|
|
self, screen: Screen | str, screen_class: Type[Screen] | None = None
|
|
) -> Screen:
|
|
"""Get an installed screen.
|
|
|
|
Example:
|
|
```python
|
|
my_screen = self.get_screen("settings", MyScreen)
|
|
```
|
|
|
|
Args:
|
|
screen: Either a Screen object or screen name (the `name` argument when installed).
|
|
screen_class: Class of expected screen, or `None` for any screen class.
|
|
|
|
Raises:
|
|
KeyError: If the named screen doesn't exist.
|
|
|
|
Returns:
|
|
A screen instance.
|
|
"""
|
|
if isinstance(screen, str):
|
|
try:
|
|
next_screen = self._installed_screens[screen]
|
|
except KeyError:
|
|
raise KeyError(f"No screen called {screen!r} installed") from None
|
|
if callable(next_screen):
|
|
next_screen = next_screen()
|
|
self._installed_screens[screen] = next_screen
|
|
else:
|
|
next_screen = screen
|
|
if screen_class is not None and not isinstance(next_screen, screen_class):
|
|
raise TypeError(
|
|
f"Expected a screen of type {screen_class}, got {type(next_screen)}"
|
|
)
|
|
return next_screen
|
|
|
|
def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]:
|
|
"""Get an installed screen and an AwaitMount object.
|
|
|
|
If the screen isn't running, it will be registered before it is run.
|
|
|
|
Args:
|
|
screen: Either a Screen object or screen name (the `name` argument when installed).
|
|
|
|
Raises:
|
|
KeyError: If the named screen doesn't exist.
|
|
|
|
Returns:
|
|
A screen instance and an awaitable that awaits the children mounting.
|
|
"""
|
|
_screen = self.get_screen(screen)
|
|
if not _screen.is_running:
|
|
widgets = self._register(self, _screen)
|
|
await_mount = AwaitMount(_screen, widgets)
|
|
self.call_next(await_mount)
|
|
return (_screen, await_mount)
|
|
else:
|
|
await_mount = AwaitMount(_screen, [])
|
|
self.call_next(await_mount)
|
|
return (_screen, await_mount)
|
|
|
|
def _load_screen_css(self, screen: Screen):
|
|
"""Loads the CSS associated with a screen."""
|
|
|
|
if self.css_monitor is not None:
|
|
self.css_monitor.add_paths(screen.css_path)
|
|
|
|
update = False
|
|
for path in screen.css_path:
|
|
if not self.stylesheet.has_source(str(path), ""):
|
|
self.stylesheet.read(path)
|
|
update = True
|
|
if screen.CSS:
|
|
try:
|
|
screen_path = inspect.getfile(screen.__class__)
|
|
except (TypeError, OSError):
|
|
screen_path = ""
|
|
screen_class_var = f"{screen.__class__.__name__}.CSS"
|
|
read_from = (screen_path, screen_class_var)
|
|
if not self.stylesheet.has_source(screen_path, screen_class_var):
|
|
self.stylesheet.add_source(
|
|
screen.CSS,
|
|
read_from=read_from,
|
|
is_default_css=False,
|
|
scope=screen._css_type_name if screen.SCOPED_CSS else "",
|
|
)
|
|
update = True
|
|
if update:
|
|
self.stylesheet.reparse()
|
|
self.stylesheet.update(self)
|
|
|
|
async def _replace_screen(self, screen: Screen) -> Screen:
|
|
"""Handle the replaced screen.
|
|
|
|
Args:
|
|
screen: A screen object.
|
|
|
|
Returns:
|
|
The screen that was replaced.
|
|
"""
|
|
if self._screen_stack:
|
|
self.screen.refresh()
|
|
screen.post_message(events.ScreenSuspend())
|
|
self.log.system(f"{screen} SUSPENDED")
|
|
if not self.is_screen_installed(screen) and all(
|
|
screen not in stack for stack in self._screen_stacks.values()
|
|
):
|
|
self.capture_mouse(None)
|
|
await screen.remove()
|
|
self.log.system(f"{screen} REMOVED")
|
|
return screen
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
@overload
|
|
def push_screen(
|
|
self,
|
|
screen: Screen[ScreenResultType] | str,
|
|
callback: ScreenResultCallbackType[ScreenResultType] | None = None,
|
|
wait_for_dismiss: Literal[False] = False,
|
|
) -> AwaitMount: ...
|
|
|
|
@overload
|
|
def push_screen(
|
|
self,
|
|
screen: Screen[ScreenResultType] | str,
|
|
callback: ScreenResultCallbackType[ScreenResultType] | None = None,
|
|
wait_for_dismiss: Literal[True] = True,
|
|
) -> asyncio.Future[ScreenResultType]: ...
|
|
|
|
def push_screen(
|
|
self,
|
|
screen: Screen[ScreenResultType] | str,
|
|
callback: ScreenResultCallbackType[ScreenResultType] | None = None,
|
|
wait_for_dismiss: bool = False,
|
|
) -> AwaitMount | asyncio.Future[ScreenResultType]:
|
|
"""Push a new [screen](/guide/screens) on the screen stack, making it the current screen.
|
|
|
|
Args:
|
|
screen: A Screen instance or the name of an installed screen.
|
|
callback: An optional callback function that will be called if the screen is [dismissed][textual.screen.Screen.dismiss] with a result.
|
|
wait_for_dismiss: If `True`, awaiting this method will return the dismiss value from the screen. When set to `False`, awaiting
|
|
this method will wait for the screen to be mounted. Note that `wait_for_dismiss` should only be set to `True` when running in a worker.
|
|
|
|
Raises:
|
|
NoActiveWorker: If using `wait_for_dismiss` outside of a worker.
|
|
|
|
Returns:
|
|
An optional awaitable that awaits the mounting of the screen and its children, or an asyncio Future
|
|
to await the result of the screen.
|
|
"""
|
|
if not isinstance(screen, (Screen, str)):
|
|
raise TypeError(
|
|
f"push_screen requires a Screen instance or str; not {screen!r}"
|
|
)
|
|
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
# Mainly for testing, when push_screen isn't called in an async context
|
|
future: asyncio.Future[ScreenResultType] = asyncio.Future()
|
|
else:
|
|
future = loop.create_future()
|
|
|
|
self.app.capture_mouse(None)
|
|
if self._screen_stack:
|
|
self.screen.post_message(events.ScreenSuspend())
|
|
self.screen.refresh()
|
|
next_screen, await_mount = self._get_screen(screen)
|
|
try:
|
|
message_pump = active_message_pump.get()
|
|
except LookupError:
|
|
message_pump = self.app
|
|
|
|
next_screen._push_result_callback(message_pump, callback, future)
|
|
self._load_screen_css(next_screen)
|
|
next_screen._update_auto_focus()
|
|
self._screen_stack.append(next_screen)
|
|
next_screen.post_message(events.ScreenResume())
|
|
self.log.system(f"{self.screen} is current (PUSHED)")
|
|
if wait_for_dismiss:
|
|
try:
|
|
get_current_worker()
|
|
except NoActiveWorker:
|
|
raise NoActiveWorker(
|
|
"push_screen must be run from a worker when `wait_for_dismiss` is True"
|
|
) from None
|
|
return future
|
|
else:
|
|
return await_mount
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
@overload
|
|
async def push_screen_wait(
|
|
self, screen: Screen[ScreenResultType]
|
|
) -> ScreenResultType: ...
|
|
|
|
@overload
|
|
async def push_screen_wait(self, screen: str) -> Any: ...
|
|
|
|
async def push_screen_wait(
|
|
self, screen: Screen[ScreenResultType] | str
|
|
) -> ScreenResultType | Any:
|
|
"""Push a screen and wait for the result (received from [`Screen.dismiss`][textual.screen.Screen.dismiss]).
|
|
|
|
Note that this method may only be called when running in a worker.
|
|
|
|
Args:
|
|
screen: A screen or the name of an installed screen.
|
|
|
|
Returns:
|
|
The screen's result.
|
|
"""
|
|
await self._flush_next_callbacks()
|
|
# The shield prevents the cancellation of the current task from canceling the push_screen awaitable
|
|
return await asyncio.shield(self.push_screen(screen, wait_for_dismiss=True))
|
|
|
|
def switch_screen(self, screen: Screen | str) -> AwaitComplete:
|
|
"""Switch to another [screen](/guide/screens) by replacing the top of the screen stack with a new screen.
|
|
|
|
Args:
|
|
screen: Either a Screen object or screen name (the `name` argument when installed).
|
|
"""
|
|
if not isinstance(screen, (Screen, str)):
|
|
raise TypeError(
|
|
f"switch_screen requires a Screen instance or str; not {screen!r}"
|
|
)
|
|
|
|
next_screen, await_mount = self._get_screen(screen)
|
|
if screen is self.screen or next_screen is self.screen:
|
|
self.log.system(f"Screen {screen} is already current.")
|
|
return AwaitComplete.nothing()
|
|
|
|
self.app.capture_mouse(None)
|
|
top_screen = self._screen_stack.pop()
|
|
|
|
top_screen._pop_result_callback()
|
|
self._load_screen_css(next_screen)
|
|
self._screen_stack.append(next_screen)
|
|
self.screen.post_message(events.ScreenResume())
|
|
self.screen._push_result_callback(self.screen, None)
|
|
self.log.system(f"{self.screen} is current (SWITCHED)")
|
|
|
|
async def do_switch() -> None:
|
|
"""Task to perform switch."""
|
|
|
|
await await_mount()
|
|
await self._replace_screen(top_screen)
|
|
|
|
return AwaitComplete(do_switch()).call_next(self)
|
|
|
|
def install_screen(self, screen: Screen, name: str) -> None:
|
|
"""Install a screen.
|
|
|
|
Installing a screen prevents Textual from destroying it when it is no longer on the screen stack.
|
|
Note that you don't need to install a screen to use it. See [push_screen][textual.app.App.push_screen]
|
|
or [switch_screen][textual.app.App.switch_screen] to make a new screen current.
|
|
|
|
Args:
|
|
screen: Screen to install.
|
|
name: Unique name to identify the screen.
|
|
|
|
Raises:
|
|
ScreenError: If the screen can't be installed.
|
|
|
|
Returns:
|
|
An awaitable that awaits the mounting of the screen and its children.
|
|
"""
|
|
if name in self._installed_screens:
|
|
raise ScreenError(f"Can't install screen; {name!r} is already installed")
|
|
if screen in self._installed_screens.values():
|
|
raise ScreenError(
|
|
f"Can't install screen; {screen!r} has already been installed"
|
|
)
|
|
self._installed_screens[name] = screen
|
|
self.log.system(f"{screen} INSTALLED name={name!r}")
|
|
|
|
def uninstall_screen(self, screen: Screen | str) -> str | None:
|
|
"""Uninstall a screen.
|
|
|
|
If the screen was not previously installed, then this method is a null-op.
|
|
Uninstalling a screen allows Textual to delete it when it is popped or switched.
|
|
Note that uninstalling a screen is only required if you have previously installed it
|
|
with [install_screen][textual.app.App.install_screen].
|
|
Textual will also uninstall screens automatically on exit.
|
|
|
|
Args:
|
|
screen: The screen to uninstall or the name of an installed screen.
|
|
|
|
Returns:
|
|
The name of the screen that was uninstalled, or None if no screen was uninstalled.
|
|
"""
|
|
if isinstance(screen, str):
|
|
if screen not in self._installed_screens:
|
|
return None
|
|
uninstall_screen = self._installed_screens[screen]
|
|
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 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:
|
|
self._installed_screens.pop(name)
|
|
self.log.system(f"{screen} UNINSTALLED name={name!r}")
|
|
return name
|
|
return None
|
|
|
|
def pop_screen(self) -> AwaitComplete:
|
|
"""Pop the current [screen](/guide/screens) from the stack, and switch to the previous screen.
|
|
|
|
Returns:
|
|
The screen that was replaced.
|
|
"""
|
|
|
|
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"
|
|
)
|
|
|
|
previous_screen = screen_stack.pop()
|
|
previous_screen._pop_result_callback()
|
|
self.screen.post_message(events.ScreenResume())
|
|
self.log.system(f"{self.screen} is active")
|
|
|
|
async def do_pop() -> None:
|
|
"""Task to pop the screen."""
|
|
await self._replace_screen(previous_screen)
|
|
|
|
return AwaitComplete(do_pop()).call_next(self)
|
|
|
|
def _pop_to_screen(self, screen: Screen) -> None:
|
|
"""Pop screens until the given screen is active.
|
|
|
|
Args:
|
|
screen: desired active screen
|
|
|
|
Raises:
|
|
ScreenError: If the screen doesn't exist in the stack.
|
|
"""
|
|
screens_to_pop: list[Screen] = []
|
|
for pop_screen in reversed(self.screen_stack):
|
|
if pop_screen is not screen:
|
|
screens_to_pop.append(pop_screen)
|
|
else:
|
|
break
|
|
else:
|
|
raise ScreenError(f"Screen {screen!r} not in screen stack")
|
|
|
|
async def pop_screens() -> None:
|
|
"""Pop any screens in `screens_to_pop`."""
|
|
with self.batch_update():
|
|
for screen in screens_to_pop:
|
|
await screen.dismiss()
|
|
|
|
if screens_to_pop:
|
|
self.call_later(pop_screens)
|
|
|
|
def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
|
|
"""Focus (or unfocus) a widget. A focused widget will receive key events first.
|
|
|
|
Args:
|
|
widget: Widget to focus.
|
|
scroll_visible: Scroll widget into view.
|
|
"""
|
|
self.screen.set_focus(widget, scroll_visible)
|
|
|
|
def _set_mouse_over(
|
|
self, widget: Widget | None, hover_widget: Widget | None
|
|
) -> None:
|
|
"""Called when the mouse is over another widget.
|
|
|
|
Args:
|
|
widget: Widget under mouse, or None for no widgets.
|
|
"""
|
|
if widget is None:
|
|
if self.mouse_over is not None:
|
|
try:
|
|
self.mouse_over.post_message(events.Leave(self.mouse_over))
|
|
finally:
|
|
self.mouse_over = None
|
|
else:
|
|
if self.mouse_over is not widget:
|
|
try:
|
|
if self.mouse_over is not None:
|
|
self.mouse_over.post_message(events.Leave(self.mouse_over))
|
|
if widget is not None:
|
|
widget.post_message(events.Enter(widget))
|
|
finally:
|
|
self.mouse_over = widget
|
|
|
|
current_hover_over = self.hover_over
|
|
if current_hover_over is not None:
|
|
current_hover_over.mouse_hover = False
|
|
|
|
if hover_widget is not None:
|
|
hover_widget.mouse_hover = True
|
|
if hover_widget._has_hover_style:
|
|
hover_widget._update_styles()
|
|
if current_hover_over is not None and current_hover_over._has_hover_style:
|
|
current_hover_over._update_styles()
|
|
self.hover_over = hover_widget
|
|
|
|
def _update_mouse_over(self, screen: Screen) -> None:
|
|
"""Updates the mouse over after the next refresh.
|
|
|
|
This method is called whenever a widget is added or removed, which may change
|
|
the widget under the mouse.
|
|
|
|
"""
|
|
|
|
if self.mouse_over is None or not screen.is_active:
|
|
return
|
|
|
|
async def check_mouse() -> None:
|
|
"""Check if the mouse over widget has changed."""
|
|
try:
|
|
hover_widgets = screen.get_hover_widgets_at(*self.mouse_position)
|
|
except NoWidget:
|
|
pass
|
|
else:
|
|
mouse_over, hover_over = hover_widgets.widgets
|
|
if (
|
|
mouse_over is not self.mouse_over
|
|
or hover_over is not self.hover_over
|
|
):
|
|
self._set_mouse_over(mouse_over, hover_over)
|
|
|
|
self.call_after_refresh(check_mouse)
|
|
|
|
def capture_mouse(self, widget: Widget | None) -> None:
|
|
"""Send all mouse events to the given widget or disable mouse capture.
|
|
|
|
Normally mouse events are sent to the widget directly under the pointer.
|
|
Capturing the mouse allows a widget to receive mouse events even when the pointer is over another widget.
|
|
|
|
Args:
|
|
widget: Widget to capture mouse events, or `None` to end mouse capture.
|
|
"""
|
|
if widget == self.mouse_captured:
|
|
return
|
|
if self.mouse_captured is not None:
|
|
self.mouse_captured.post_message(events.MouseRelease(self.mouse_position))
|
|
self.mouse_captured = widget
|
|
if widget is not None:
|
|
widget.post_message(events.MouseCapture(self.mouse_position))
|
|
|
|
def panic(self, *renderables: RenderableType) -> None:
|
|
"""Exits the app and display error message(s).
|
|
|
|
Used in response to unexpected errors.
|
|
For a more graceful exit, see the [exit][textual.app.App.exit] method.
|
|
|
|
Args:
|
|
*renderables: Text or Rich renderable(s) to display on exit.
|
|
"""
|
|
assert all(
|
|
is_renderable(renderable) for renderable in renderables
|
|
), "Can only call panic with strings or Rich renderables"
|
|
|
|
def render(renderable: RenderableType) -> list[Segment]:
|
|
"""Render a panic renderables."""
|
|
segments = list(self.console.render(renderable, self.console.options))
|
|
return segments
|
|
|
|
pre_rendered = [Segments(render(renderable)) for renderable in renderables]
|
|
self._exit_renderables.extend(pre_rendered)
|
|
|
|
self._close_messages_no_wait()
|
|
|
|
def _handle_exception(self, error: Exception) -> None:
|
|
"""Called with an unhandled exception.
|
|
|
|
Always results in the app exiting.
|
|
|
|
Args:
|
|
error: An exception instance.
|
|
"""
|
|
self._return_code = 1
|
|
# If we're running via pilot and this is the first exception encountered,
|
|
# take note of it so that we can re-raise for test frameworks later.
|
|
if self._exception is None:
|
|
self._exception = error
|
|
self._exception_event.set()
|
|
|
|
if hasattr(error, "__rich__"):
|
|
# Exception has a rich method, so we can defer to that for the rendering
|
|
self.panic(error)
|
|
else:
|
|
# Use default exception rendering
|
|
self._fatal_error()
|
|
|
|
def _fatal_error(self) -> None:
|
|
"""Exits the app after an unhandled exception."""
|
|
from rich.traceback import Traceback
|
|
|
|
self.bell()
|
|
traceback = Traceback(
|
|
show_locals=True, width=None, locals_max_length=5, suppress=[rich]
|
|
)
|
|
self._exit_renderables.append(
|
|
Segments(self.console.render(traceback, self.console.options))
|
|
)
|
|
self._close_messages_no_wait()
|
|
|
|
def _print_error_renderables(self) -> None:
|
|
"""Print and clear exit renderables."""
|
|
error_count = len(self._exit_renderables)
|
|
if "debug" in self.features:
|
|
for renderable in self._exit_renderables:
|
|
self.error_console.print(renderable)
|
|
if error_count > 1:
|
|
self.error_console.print(
|
|
f"\n[b]NOTE:[/b] {error_count} errors shown above.", markup=True
|
|
)
|
|
elif self._exit_renderables:
|
|
self.error_console.print(self._exit_renderables[0])
|
|
if error_count > 1:
|
|
self.error_console.print(
|
|
f"\n[b]NOTE:[/b] 1 of {error_count} errors shown. Run with [b]textual run --dev[/] to see all errors.",
|
|
markup=True,
|
|
)
|
|
|
|
self._exit_renderables.clear()
|
|
|
|
def _build_driver(
|
|
self, headless: bool, inline: bool, mouse: bool, size: tuple[int, int] | None
|
|
) -> Driver:
|
|
"""Construct a driver instance.
|
|
|
|
Args:
|
|
headless: Request headless driver.
|
|
inline: Request inline driver.
|
|
mouse: Request mouse support.
|
|
size: Initial size.
|
|
|
|
Returns:
|
|
Driver instance.
|
|
"""
|
|
driver: Driver
|
|
driver_class: type[Driver]
|
|
if headless:
|
|
from textual.drivers.headless_driver import HeadlessDriver
|
|
|
|
driver_class = HeadlessDriver
|
|
elif inline and not WINDOWS:
|
|
from textual.drivers.linux_inline_driver import LinuxInlineDriver
|
|
|
|
driver_class = LinuxInlineDriver
|
|
else:
|
|
driver_class = self.driver_class
|
|
|
|
driver = self._driver = driver_class(
|
|
self,
|
|
debug=constants.DEBUG,
|
|
mouse=mouse,
|
|
size=size,
|
|
)
|
|
return driver
|
|
|
|
async def _init_devtools(self):
|
|
"""Initialize developer tools."""
|
|
if self.devtools is not None:
|
|
from textual_dev.client import DevtoolsConnectionError
|
|
|
|
try:
|
|
await self.devtools.connect()
|
|
self.log.system(f"Connected to devtools ( {self.devtools.url} )")
|
|
except DevtoolsConnectionError:
|
|
self.log.system(f"Couldn't connect to devtools ( {self.devtools.url} )")
|
|
|
|
async def _process_messages(
|
|
self,
|
|
ready_callback: CallbackType | None = None,
|
|
headless: bool = False,
|
|
inline: bool = False,
|
|
inline_no_clear: bool = False,
|
|
mouse: bool = True,
|
|
terminal_size: tuple[int, int] | None = None,
|
|
message_hook: Callable[[Message], None] | None = None,
|
|
) -> None:
|
|
self._thread_init()
|
|
|
|
async def app_prelude() -> bool:
|
|
"""Work required before running the app.
|
|
|
|
Returns:
|
|
`True` if the app should continue, or `False` if there was a problem starting.
|
|
"""
|
|
await self._init_devtools()
|
|
self.log.system("---")
|
|
self.log.system(loop=asyncio.get_running_loop())
|
|
self.log.system(features=self.features)
|
|
if constants.LOG_FILE is not None:
|
|
_log_path = os.path.abspath(constants.LOG_FILE)
|
|
self.log.system(f"Writing logs to {_log_path!r}")
|
|
|
|
try:
|
|
if self.css_path:
|
|
self.stylesheet.read_all(self.css_path)
|
|
for read_from, css, tie_breaker, scope in self._get_default_css():
|
|
self.stylesheet.add_source(
|
|
css,
|
|
read_from=read_from,
|
|
is_default_css=True,
|
|
tie_breaker=tie_breaker,
|
|
scope=scope,
|
|
)
|
|
if self.CSS:
|
|
try:
|
|
app_path = inspect.getfile(self.__class__)
|
|
except (TypeError, OSError):
|
|
app_path = ""
|
|
read_from = (app_path, f"{self.__class__.__name__}.CSS")
|
|
self.stylesheet.add_source(
|
|
self.CSS, read_from=read_from, is_default_css=False
|
|
)
|
|
except Exception as error:
|
|
self._handle_exception(error)
|
|
self._print_error_renderables()
|
|
return False
|
|
|
|
if self.css_monitor:
|
|
self.set_interval(0.25, self.css_monitor, name="css monitor")
|
|
self.log.system("STARTED", self.css_monitor)
|
|
return True
|
|
|
|
async def run_process_messages():
|
|
"""The main message loop, invoke below."""
|
|
|
|
async def invoke_ready_callback() -> None:
|
|
if ready_callback is not None:
|
|
ready_result = ready_callback()
|
|
if inspect.isawaitable(ready_result):
|
|
await ready_result
|
|
|
|
with self.batch_update():
|
|
try:
|
|
try:
|
|
await self._dispatch_message(events.Compose())
|
|
await self._dispatch_message(
|
|
events.Resize.from_dimensions(self.size, None)
|
|
)
|
|
default_screen = self.screen
|
|
self.stylesheet.apply(self)
|
|
await self._dispatch_message(events.Mount())
|
|
self.check_idle()
|
|
finally:
|
|
self._mounted_event.set()
|
|
self._is_mounted = True
|
|
|
|
Reactive._initialize_object(self)
|
|
|
|
if self.screen is not default_screen:
|
|
self.stylesheet.apply(default_screen)
|
|
|
|
await self.animator.start()
|
|
|
|
except Exception:
|
|
await self.animator.stop()
|
|
raise
|
|
|
|
finally:
|
|
self._running = True
|
|
await self._ready()
|
|
await invoke_ready_callback()
|
|
|
|
try:
|
|
await self._process_messages_loop()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
self.workers.cancel_all()
|
|
self._running = False
|
|
try:
|
|
await self.animator.stop()
|
|
finally:
|
|
await Timer._stop_all(self._timers)
|
|
|
|
with self._context():
|
|
if not await app_prelude():
|
|
return
|
|
self._running = True
|
|
try:
|
|
load_event = events.Load()
|
|
await self._dispatch_message(load_event)
|
|
|
|
driver = self._driver = self._build_driver(
|
|
headless=headless,
|
|
inline=inline,
|
|
mouse=mouse,
|
|
size=terminal_size,
|
|
)
|
|
self.log(driver=driver)
|
|
|
|
if not self._exit:
|
|
driver.start_application_mode()
|
|
try:
|
|
with redirect_stdout(self._capture_stdout):
|
|
with redirect_stderr(self._capture_stderr):
|
|
await run_process_messages()
|
|
|
|
finally:
|
|
Reactive._clear_watchers(self)
|
|
if self._driver.is_inline:
|
|
cursor_x, cursor_y = self._previous_cursor_position
|
|
self._driver.write(
|
|
Control.move(-cursor_x, -cursor_y).segment.text
|
|
)
|
|
self._driver.flush()
|
|
if inline_no_clear and not self.app._exit_renderables:
|
|
console = Console()
|
|
try:
|
|
console.print(self.screen._compositor)
|
|
except ScreenStackError:
|
|
console.print()
|
|
else:
|
|
self._driver.write(
|
|
Control.move(0, -self.INLINE_PADDING).segment.text
|
|
)
|
|
|
|
driver.stop_application_mode()
|
|
except Exception as error:
|
|
self._handle_exception(error)
|
|
|
|
async def _pre_process(self) -> bool:
|
|
"""Special case for the app, which doesn't need the functionality in MessagePump."""
|
|
return True
|
|
|
|
async def _ready(self) -> None:
|
|
"""Called immediately prior to processing messages.
|
|
|
|
May be used as a hook for any operations that should run first.
|
|
"""
|
|
|
|
ready_time = (perf_counter() - self._start_time) * 1000
|
|
self.log.system(f"ready in {ready_time:0.0f} milliseconds")
|
|
|
|
async def take_screenshot() -> None:
|
|
"""Take a screenshot and exit."""
|
|
self.save_screenshot(
|
|
path=constants.SCREENSHOT_LOCATION,
|
|
filename=constants.SCREENSHOT_FILENAME,
|
|
)
|
|
self.exit()
|
|
|
|
if constants.SCREENSHOT_DELAY >= 0:
|
|
self.set_timer(
|
|
constants.SCREENSHOT_DELAY, take_screenshot, name="screenshot timer"
|
|
)
|
|
|
|
async def _on_compose(self) -> None:
|
|
_rich_traceback_omit = True
|
|
self._compose_screen = self.screen
|
|
try:
|
|
widgets = [*self.screen._nodes, *compose(self)]
|
|
except TypeError as error:
|
|
raise TypeError(
|
|
f"{self!r} compose() method returned an invalid result; {error}"
|
|
) from error
|
|
|
|
await self.mount_all(widgets)
|
|
|
|
async def _check_recompose(self) -> None:
|
|
"""Check if a recompose is required."""
|
|
if self._recompose_required:
|
|
self._recompose_required = False
|
|
await self.recompose()
|
|
|
|
async def recompose(self) -> None:
|
|
"""Recompose the widget.
|
|
|
|
Recomposing will remove children and call `self.compose` again to remount.
|
|
"""
|
|
if self._exit:
|
|
return
|
|
try:
|
|
async with self.screen.batch():
|
|
await self.screen.query("*").exclude(".-textual-system").remove()
|
|
await self.screen.mount_all(compose(self))
|
|
except ScreenStackError:
|
|
pass
|
|
|
|
def _register_child(
|
|
self, parent: DOMNode, child: Widget, before: int | None, after: int | None
|
|
) -> None:
|
|
"""Register a widget as a child of another.
|
|
|
|
Args:
|
|
parent: Parent node.
|
|
child: The child widget to register.
|
|
before: A location to mount before.
|
|
after: A location to mount after.
|
|
"""
|
|
|
|
# Let's be 100% sure that we've not been asked to do a before and an
|
|
# after at the same time. It's possible that we can remove this
|
|
# check later on, but for the purposes of development right now,
|
|
# it's likely a good idea to keep it here to check assumptions in
|
|
# the rest of the code.
|
|
if before is not None and after is not None:
|
|
raise AppError("Only one of 'before' and 'after' may be specified.")
|
|
|
|
# If we don't already know about this widget...
|
|
if child not in self._registry:
|
|
# Now to figure out where to place it. If we've got a `before`...
|
|
if before is not None:
|
|
# ...it's safe to NodeList._insert before that location.
|
|
parent._nodes._insert(before, child)
|
|
elif after is not None and after != -1:
|
|
# In this case we've got an after. -1 holds the special
|
|
# position (for now) of meaning "okay really what I mean is
|
|
# do an append, like if I'd asked to add with no before or
|
|
# after". So... we insert before the next item in the node
|
|
# list, if after isn't -1.
|
|
parent._nodes._insert(after + 1, child)
|
|
else:
|
|
# At this point we appear to not be adding before or after,
|
|
# or we've got a before/after value that really means
|
|
# "please append". So...
|
|
parent._nodes._append(child)
|
|
|
|
# Now that the widget is in the NodeList of its parent, sort out
|
|
# the rest of the admin.
|
|
self._registry.add(child)
|
|
child._attach(parent)
|
|
child._post_register(self)
|
|
|
|
def _register(
|
|
self,
|
|
parent: DOMNode,
|
|
*widgets: Widget,
|
|
before: int | None = None,
|
|
after: int | None = None,
|
|
cache: dict[tuple, RulesMap] | None = None,
|
|
) -> list[Widget]:
|
|
"""Register widget(s) so they may receive events.
|
|
|
|
Args:
|
|
parent: Parent node.
|
|
*widgets: The widget(s) to register.
|
|
before: A location to mount before.
|
|
after: A location to mount after.
|
|
cache: Optional rules map cache.
|
|
|
|
Returns:
|
|
List of modified widgets.
|
|
"""
|
|
|
|
if not widgets:
|
|
return []
|
|
|
|
if cache is None:
|
|
cache = {}
|
|
widget_list: Iterable[Widget]
|
|
if before is not None or after is not None:
|
|
# There's a before or after, which means there's going to be an
|
|
# insertion, so make it easier to get the new things in the
|
|
# correct order.
|
|
widget_list = reversed(widgets)
|
|
else:
|
|
widget_list = widgets
|
|
|
|
apply_stylesheet = self.stylesheet.apply
|
|
new_widgets: list[Widget] = []
|
|
add_new_widget = new_widgets.append
|
|
for widget in widget_list:
|
|
widget._closing = False
|
|
widget._closed = False
|
|
widget._pruning = False
|
|
if not isinstance(widget, Widget):
|
|
raise AppError(f"Can't register {widget!r}; expected a Widget instance")
|
|
if widget not in self._registry:
|
|
add_new_widget(widget)
|
|
self._register_child(parent, widget, before, after)
|
|
if widget._nodes:
|
|
self._register(widget, *widget._nodes, cache=cache)
|
|
for widget in new_widgets:
|
|
apply_stylesheet(widget, cache=cache)
|
|
widget._start_messages()
|
|
|
|
if not self._running:
|
|
# If the app is not running, prevent awaiting of the widget tasks
|
|
return []
|
|
|
|
return list(widgets)
|
|
|
|
def _unregister(self, widget: Widget) -> None:
|
|
"""Unregister a widget.
|
|
|
|
Args:
|
|
widget: A Widget to unregister
|
|
"""
|
|
widget.blur()
|
|
if isinstance(widget._parent, Widget):
|
|
widget._parent._nodes._remove(widget)
|
|
widget._detach()
|
|
self._registry.discard(widget)
|
|
|
|
async def _disconnect_devtools(self):
|
|
if self.devtools is not None:
|
|
await self.devtools.disconnect()
|
|
|
|
def _start_widget(self, parent: Widget, widget: Widget) -> None:
|
|
"""Start a widget (run its task) so that it can receive messages.
|
|
|
|
Args:
|
|
parent: The parent of the Widget.
|
|
widget: The Widget to start.
|
|
"""
|
|
|
|
widget._attach(parent)
|
|
widget._start_messages()
|
|
self.app._registry.add(widget)
|
|
|
|
def is_mounted(self, widget: Widget) -> bool:
|
|
"""Check if a widget is mounted.
|
|
|
|
Args:
|
|
widget: A widget.
|
|
|
|
Returns:
|
|
True of the widget is mounted.
|
|
"""
|
|
return widget in self._registry
|
|
|
|
async def _close_all(self) -> None:
|
|
"""Close all message pumps."""
|
|
|
|
# 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(stack_screen)
|
|
stack.clear()
|
|
self._installed_screens.clear()
|
|
self._modes.clear()
|
|
|
|
# Close any remaining nodes
|
|
# Should be empty by now
|
|
remaining_nodes = list(self._registry)
|
|
for child in remaining_nodes:
|
|
await child._close_messages()
|
|
|
|
async def _shutdown(self) -> None:
|
|
self._begin_batch() # Prevents any layout / repaint while shutting down
|
|
driver = self._driver
|
|
self._running = False
|
|
if driver is not None:
|
|
driver.disable_input()
|
|
|
|
await self._close_all()
|
|
await self._close_messages()
|
|
await self._dispatch_message(events.Unmount())
|
|
|
|
if self._driver is not None:
|
|
self._driver.close()
|
|
|
|
self._nodes._clear()
|
|
|
|
if self.devtools is not None and self.devtools.is_connected:
|
|
await self._disconnect_devtools()
|
|
|
|
self._print_error_renderables()
|
|
|
|
if constants.SHOW_RETURN:
|
|
from rich.console import Console
|
|
from rich.pretty import Pretty
|
|
|
|
console = Console()
|
|
console.print("[b]The app returned:")
|
|
console.print(Pretty(self._return_value))
|
|
|
|
async def _on_exit_app(self) -> None:
|
|
self._begin_batch() # Prevent repaint / layout while shutting down
|
|
self._message_queue.put_nowait(None)
|
|
|
|
def refresh(
|
|
self,
|
|
*,
|
|
repaint: bool = True,
|
|
layout: bool = False,
|
|
recompose: bool = False,
|
|
) -> Self:
|
|
"""Refresh the entire screen.
|
|
|
|
Args:
|
|
repaint: Repaint the widget (will call render() again).
|
|
layout: Also layout widgets in the view.
|
|
recompose: Re-compose the widget (will remove and re-mount children).
|
|
|
|
Returns:
|
|
The `App` instance.
|
|
"""
|
|
if recompose:
|
|
self._recompose_required = recompose
|
|
self.call_next(self._check_recompose)
|
|
return self
|
|
|
|
if self._screen_stack:
|
|
self.screen.refresh(repaint=repaint, layout=layout)
|
|
self.check_idle()
|
|
return self
|
|
|
|
def refresh_css(self, animate: bool = True) -> None:
|
|
"""Refresh CSS.
|
|
|
|
Args:
|
|
animate: Also execute CSS animations.
|
|
"""
|
|
stylesheet = self.app.stylesheet
|
|
stylesheet.set_variables(self.get_css_variables())
|
|
stylesheet.reparse()
|
|
stylesheet.update(self.app, animate=animate)
|
|
try:
|
|
if self.screen.is_mounted:
|
|
self.screen._refresh_layout(self.size)
|
|
self.screen._css_update_count = self._css_update_count
|
|
except ScreenError:
|
|
pass
|
|
# The other screens in the stack will need to know about some style
|
|
# changes, as a final pass let's check in on every screen that isn't
|
|
# the current one and update them too.
|
|
for screen in self.screen_stack:
|
|
if screen != self.screen:
|
|
stylesheet.update(screen, animate=animate)
|
|
screen._css_update_count = self._css_update_count
|
|
|
|
def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
|
|
"""Display a renderable within a sync.
|
|
|
|
Args:
|
|
screen: Screen instance
|
|
renderable: A Rich renderable.
|
|
"""
|
|
|
|
try:
|
|
if renderable is None:
|
|
return
|
|
if self._batch_count:
|
|
return
|
|
if (
|
|
self._running
|
|
and not self._closed
|
|
and not self.is_headless
|
|
and self._driver is not None
|
|
):
|
|
console = self.console
|
|
self._begin_update()
|
|
try:
|
|
try:
|
|
if isinstance(renderable, CompositorUpdate):
|
|
cursor_position = self.screen.outer_size.clamp_offset(
|
|
self.cursor_position
|
|
)
|
|
if self._driver.is_inline:
|
|
terminal_sequence = Control.move(
|
|
*(-self._previous_cursor_position)
|
|
).segment.text
|
|
terminal_sequence += renderable.render_segments(console)
|
|
terminal_sequence += Control.move(
|
|
*cursor_position
|
|
).segment.text
|
|
else:
|
|
terminal_sequence = renderable.render_segments(console)
|
|
terminal_sequence += Control.move_to(
|
|
*cursor_position
|
|
).segment.text
|
|
self._previous_cursor_position = cursor_position
|
|
else:
|
|
segments = console.render(renderable)
|
|
terminal_sequence = console._render_buffer(segments)
|
|
except Exception as error:
|
|
self._handle_exception(error)
|
|
else:
|
|
if WINDOWS:
|
|
# Combat a problem with Python on Windows.
|
|
#
|
|
# https://github.com/Textualize/textual/issues/2548
|
|
# https://github.com/python/cpython/issues/82052
|
|
CHUNK_SIZE = 8192
|
|
write = self._driver.write
|
|
for chunk in (
|
|
terminal_sequence[offset : offset + CHUNK_SIZE]
|
|
for offset in range(
|
|
0, len(terminal_sequence), CHUNK_SIZE
|
|
)
|
|
):
|
|
write(chunk)
|
|
else:
|
|
self._driver.write(terminal_sequence)
|
|
finally:
|
|
self._end_update()
|
|
|
|
self._driver.flush()
|
|
|
|
finally:
|
|
self.post_display_hook()
|
|
|
|
def post_display_hook(self) -> None:
|
|
"""Called immediately after a display is done. Used in tests."""
|
|
|
|
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
|
|
"""Get the widget under the given coordinates.
|
|
|
|
Args:
|
|
x: X coordinate.
|
|
y: Y coordinate.
|
|
|
|
Returns:
|
|
The widget and the widget's screen region.
|
|
"""
|
|
return self.screen.get_widget_at(x, y)
|
|
|
|
def bell(self) -> None:
|
|
"""Play the console 'bell'.
|
|
|
|
For terminals that support a bell, this typically makes a notification or error sound.
|
|
Some terminals may make no sound or display a visual bell indicator, depending on configuration.
|
|
"""
|
|
if not self.is_headless and self._driver is not None:
|
|
self._driver.write("\07")
|
|
|
|
@property
|
|
def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
|
|
"""Get a chain of nodes and bindings to consider.
|
|
|
|
If no widget is focused, returns the bindings from both the screen and the app level bindings.
|
|
Otherwise, combines all the bindings from the currently focused node up the DOM to the root App.
|
|
"""
|
|
focused = self.focused
|
|
namespace_bindings: list[tuple[DOMNode, BindingsMap]]
|
|
|
|
if focused is None:
|
|
namespace_bindings = [
|
|
(self.screen, self.screen._bindings),
|
|
(self, self._bindings),
|
|
]
|
|
else:
|
|
namespace_bindings = [
|
|
(node, node._bindings) for node in focused.ancestors_with_self
|
|
]
|
|
|
|
return namespace_bindings
|
|
|
|
def simulate_key(self, key: str) -> None:
|
|
"""Simulate a key press.
|
|
|
|
This will perform the same action as if the user had pressed the key.
|
|
|
|
Args:
|
|
key: Key to simulate. May also be the name of a key, e.g. "space".
|
|
"""
|
|
self.post_message(events.Key(key, None))
|
|
|
|
async def _check_bindings(self, key: str, priority: bool = False) -> bool:
|
|
"""Handle a key press.
|
|
|
|
This method is used internally by the bindings system.
|
|
|
|
Args:
|
|
key: A key.
|
|
priority: If `True` check from `App` down, otherwise from focused up.
|
|
|
|
Returns:
|
|
True if the key was handled by a binding, otherwise False
|
|
"""
|
|
for namespace, bindings in (
|
|
reversed(self.screen._binding_chain)
|
|
if priority
|
|
else self.screen._modal_binding_chain
|
|
):
|
|
key_bindings = bindings.key_to_bindings.get(key, ())
|
|
for binding in key_bindings:
|
|
if binding.priority == priority:
|
|
if await self.run_action(binding.action, namespace):
|
|
return True
|
|
return False
|
|
|
|
def action_help_quit(self) -> None:
|
|
"""Bound to ctrl+C to alert the user that it no longer quits."""
|
|
# Doing this because users will reflexively hit ctrl+C to exit
|
|
# Ctrl+C is now bound to copy if an input / textarea is focused.
|
|
# This makes is possible, even likely, that a user may do it accidentally -- which would be maddening.
|
|
# Rather than do nothing, we can make an educated guess the user was trying
|
|
# to quit, and inform them how you really quit.
|
|
for key, active_binding in self.active_bindings.items():
|
|
if active_binding.binding.action in ("quit", "app.quit"):
|
|
self.notify(
|
|
f"Press [b]{key}[/b] to quit the app", title="Do you want to quit?"
|
|
)
|
|
return
|
|
|
|
@classmethod
|
|
def _normalize_keymap(cls, keymap: Keymap) -> Keymap:
|
|
"""Normalizes the keys in a keymap, so they use long form, i.e. "question_mark" rather than "?"."""
|
|
return {
|
|
binding_id: _normalize_key_list(keys) for binding_id, keys in keymap.items()
|
|
}
|
|
|
|
def set_keymap(self, keymap: Keymap) -> None:
|
|
"""Set the keymap, a mapping of binding IDs to key strings.
|
|
|
|
Bindings in the keymap are used to override default key bindings,
|
|
i.e. those defined in `BINDINGS` class variables.
|
|
|
|
Bindings with IDs that are present in the keymap will have
|
|
their key string replaced with the value from the keymap.
|
|
|
|
Args:
|
|
keymap: A mapping of binding IDs to key strings.
|
|
"""
|
|
|
|
self._keymap = self._normalize_keymap(keymap)
|
|
self.refresh_bindings()
|
|
|
|
def update_keymap(self, keymap: Keymap) -> None:
|
|
"""Update the App's keymap, merging with `keymap`.
|
|
|
|
If a Binding ID exists in both the App's keymap and the `keymap`
|
|
argument, the `keymap` argument takes precedence.
|
|
|
|
Args:
|
|
keymap: A mapping of binding IDs to key strings.
|
|
"""
|
|
|
|
self._keymap = {**self._keymap, **self._normalize_keymap(keymap)}
|
|
self.refresh_bindings()
|
|
|
|
def handle_bindings_clash(
|
|
self, clashed_bindings: set[Binding], node: DOMNode
|
|
) -> None:
|
|
"""Handle a clash between bindings.
|
|
|
|
Bindings clashes are likely due to users setting conflicting
|
|
keys via their keymap.
|
|
|
|
This method is intended to be overridden by subclasses.
|
|
|
|
Textual will call this each time a clash is encountered -
|
|
which may be on each keypress if a clashing widget is focused
|
|
or is in the bindings chain.
|
|
|
|
Args:
|
|
clashed_bindings: The bindings that are clashing.
|
|
node: The node that has the clashing bindings.
|
|
"""
|
|
pass
|
|
|
|
async def on_event(self, event: events.Event) -> None:
|
|
# 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):
|
|
await self._init_mode(self._current_mode)
|
|
await super().on_event(event)
|
|
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
|
|
if not self.app_focus and isinstance(event, (events.Key, events.MouseDown)):
|
|
self.app_focus = True
|
|
if isinstance(event, events.MouseEvent):
|
|
# Record current mouse position on App
|
|
self.mouse_position = Offset(event.x, event.y)
|
|
if isinstance(event, events.MouseDown):
|
|
try:
|
|
self._mouse_down_widget, _ = self.get_widget_at(
|
|
event.x, event.y
|
|
)
|
|
except NoWidget:
|
|
# Shouldn't occur, since at the very least this will find the Screen
|
|
self._mouse_down_widget = None
|
|
|
|
self.screen._forward_event(event)
|
|
|
|
# If a MouseUp occurs at the same widget as a MouseDown, then we should
|
|
# consider it a click, and produce a Click event.
|
|
if (
|
|
isinstance(event, events.MouseUp)
|
|
and self._mouse_down_widget is not None
|
|
):
|
|
try:
|
|
screen_offset = event.screen_offset
|
|
mouse_down_widget = self._mouse_down_widget
|
|
mouse_up_widget, _ = self.get_widget_at(*screen_offset)
|
|
if mouse_up_widget is mouse_down_widget:
|
|
same_offset = (
|
|
self._click_chain_last_offset is not None
|
|
and self._click_chain_last_offset == screen_offset
|
|
)
|
|
within_time_threshold = (
|
|
self._click_chain_last_time is not None
|
|
and event.time - self._click_chain_last_time
|
|
<= self.CLICK_CHAIN_TIME_THRESHOLD
|
|
)
|
|
|
|
if same_offset and within_time_threshold:
|
|
self._chained_clicks += 1
|
|
else:
|
|
self._chained_clicks = 1
|
|
|
|
click_event = events.Click.from_event(
|
|
mouse_down_widget, event, chain=self._chained_clicks
|
|
)
|
|
|
|
self._click_chain_last_time = event.time
|
|
self._click_chain_last_offset = screen_offset
|
|
|
|
self.screen._forward_event(click_event)
|
|
except NoWidget:
|
|
pass
|
|
|
|
elif isinstance(event, events.Key):
|
|
# Special case for maximized widgets
|
|
# If something is maximized, then escape should minimize
|
|
if (
|
|
self.screen.maximized is not None
|
|
and event.key == "escape"
|
|
and self.escape_to_minimize
|
|
):
|
|
self.screen.minimize()
|
|
return
|
|
if self.focused:
|
|
try:
|
|
self.screen._clear_tooltip()
|
|
except NoScreen:
|
|
pass
|
|
if not await self._check_bindings(event.key, priority=True):
|
|
forward_target = self.focused or self.screen
|
|
forward_target._forward_event(event)
|
|
else:
|
|
self.screen._forward_event(event)
|
|
|
|
elif isinstance(event, events.Paste) and not event.is_forwarded:
|
|
if self.focused is not None:
|
|
self.focused._forward_event(event)
|
|
else:
|
|
self.screen._forward_event(event)
|
|
else:
|
|
await super().on_event(event)
|
|
|
|
@property
|
|
def escape_to_minimize(self) -> bool:
|
|
"""Use the escape key to minimize?
|
|
|
|
When a widget is [maximized][textual.screen.Screen.maximize], this boolean determines if the `escape` key will
|
|
minimize the widget (potentially overriding any bindings).
|
|
|
|
The default logic is to use the screen's `ESCAPE_TO_MINIMIZE` classvar if it is set to `True` or `False`.
|
|
If the classvar on the screen is *not* set (and left as `None`), then the app's `ESCAPE_TO_MINIMIZE` is used.
|
|
|
|
"""
|
|
return bool(
|
|
self.ESCAPE_TO_MINIMIZE
|
|
if self.screen.ESCAPE_TO_MINIMIZE is None
|
|
else self.screen.ESCAPE_TO_MINIMIZE
|
|
)
|
|
|
|
def _parse_action(
|
|
self,
|
|
action: str | ActionParseResult,
|
|
default_namespace: DOMNode,
|
|
namespaces: Mapping[str, DOMNode] | None = None,
|
|
) -> tuple[DOMNode, str, tuple[object, ...]]:
|
|
"""Parse an action.
|
|
|
|
Args:
|
|
action: An action string.
|
|
default_namespace: Namespace to user when none is supplied in the action.
|
|
namespaces: Mapping of namespaces.
|
|
|
|
Raises:
|
|
ActionError: If there are any errors parsing the action string.
|
|
|
|
Returns:
|
|
A tuple of (node or None, action name, tuple of parameters).
|
|
"""
|
|
if isinstance(action, tuple):
|
|
destination, action_name, params = action
|
|
else:
|
|
destination, action_name, params = actions.parse(action)
|
|
|
|
action_target: DOMNode | None = (
|
|
None if namespaces is None else namespaces.get(destination)
|
|
)
|
|
if destination and action_target is None:
|
|
if destination not in self._action_targets:
|
|
raise ActionError(f"Action namespace {destination} is not known")
|
|
action_target = getattr(self, destination, None)
|
|
if action_target is None:
|
|
raise ActionError(f"Action target {destination!r} not available")
|
|
return (
|
|
(default_namespace if action_target is None else action_target),
|
|
action_name,
|
|
params,
|
|
)
|
|
|
|
def _check_action_state(
|
|
self, action: str, default_namespace: DOMNode
|
|
) -> bool | None:
|
|
"""Check if an action is enabled.
|
|
|
|
Args:
|
|
action: An action string.
|
|
default_namespace: The default namespace if one is not specified in the action.
|
|
|
|
Returns:
|
|
State of an action.
|
|
"""
|
|
action_target, action_name, parameters = self._parse_action(
|
|
action, default_namespace
|
|
)
|
|
return action_target.check_action(action_name, parameters)
|
|
|
|
async def run_action(
|
|
self,
|
|
action: str | ActionParseResult,
|
|
default_namespace: DOMNode | None = None,
|
|
namespaces: Mapping[str, DOMNode] | None = None,
|
|
) -> bool:
|
|
"""Perform an [action](/guide/actions).
|
|
|
|
Actions are typically associated with key bindings, where you wouldn't need to call this method manually.
|
|
|
|
Args:
|
|
action: Action encoded in a string.
|
|
default_namespace: Namespace to use if not provided in the action,
|
|
or None to use app.
|
|
namespaces: Mapping of namespaces.
|
|
|
|
Returns:
|
|
True if the event has been handled.
|
|
"""
|
|
action_target, action_name, params = self._parse_action(
|
|
action, self if default_namespace is None else default_namespace, namespaces
|
|
)
|
|
if action_target.check_action(action_name, params):
|
|
return await self._dispatch_action(action_target, action_name, params)
|
|
else:
|
|
return False
|
|
|
|
async def _dispatch_action(
|
|
self, namespace: DOMNode, action_name: str, params: Any
|
|
) -> bool:
|
|
"""Dispatch an action to an action method.
|
|
|
|
Args:
|
|
namespace: Namespace (object) of action.
|
|
action_name: Name of the action.
|
|
params: Action parameters.
|
|
|
|
Returns:
|
|
True if handled, otherwise False.
|
|
"""
|
|
_rich_traceback_guard = True
|
|
|
|
log.system(
|
|
"<action>",
|
|
namespace=namespace,
|
|
action_name=action_name,
|
|
params=params,
|
|
)
|
|
|
|
try:
|
|
private_method = getattr(namespace, f"_action_{action_name}", None)
|
|
if callable(private_method):
|
|
await invoke(private_method, *params)
|
|
return True
|
|
public_method = getattr(namespace, f"action_{action_name}", None)
|
|
if callable(public_method):
|
|
await invoke(public_method, *params)
|
|
return True
|
|
log.system(
|
|
f"<action> {action_name!r} has no target."
|
|
f" Could not find methods '_action_{action_name}' or 'action_{action_name}'"
|
|
)
|
|
except SkipAction:
|
|
# The action method raised this to explicitly not handle the action
|
|
log.system(f"<action> {action_name!r} skipped.")
|
|
|
|
return False
|
|
|
|
async def _broker_event(
|
|
self, event_name: str, event: events.Event, default_namespace: DOMNode
|
|
) -> bool:
|
|
"""Allow the app an opportunity to dispatch events to action system.
|
|
|
|
Args:
|
|
event_name: _description_
|
|
event: An event object.
|
|
default_namespace: The default namespace, where one isn't supplied.
|
|
|
|
Returns:
|
|
True if an action was processed.
|
|
"""
|
|
try:
|
|
style = getattr(event, "style")
|
|
except AttributeError:
|
|
return False
|
|
try:
|
|
_modifiers, action = extract_handler_actions(event_name, style.meta)
|
|
except NoHandler:
|
|
return False
|
|
else:
|
|
event.stop()
|
|
|
|
if isinstance(action, str):
|
|
await self.run_action(action, default_namespace)
|
|
elif isinstance(action, tuple) and len(action) == 2:
|
|
action_name, action_params = action
|
|
namespace, parsed_action, _ = actions.parse(action_name)
|
|
await self.run_action(
|
|
(namespace, parsed_action, action_params),
|
|
default_namespace,
|
|
)
|
|
else:
|
|
if isinstance(action, tuple) and self.debug:
|
|
# It's a tuple and made it this far, which means it'll be a
|
|
# malformed action. This is a no-op, but let's log that
|
|
# anyway.
|
|
log.warning(
|
|
f"Can't parse @{event_name} action from style meta; check your console markup syntax"
|
|
)
|
|
return False
|
|
return True
|
|
|
|
async def _on_update(self, message: messages.Update) -> None:
|
|
message.stop()
|
|
|
|
async def _on_layout(self, message: messages.Layout) -> None:
|
|
message.stop()
|
|
|
|
async def _on_key(self, event: events.Key) -> None:
|
|
if not (await self._check_bindings(event.key)):
|
|
await dispatch_key(self, event)
|
|
|
|
async def _on_resize(self, event: events.Resize) -> None:
|
|
event.stop()
|
|
self._size = event.size
|
|
self._resize_event = event
|
|
|
|
async def _on_app_focus(self, event: events.AppFocus) -> None:
|
|
"""App has focus."""
|
|
# Required by textual-web to manage focus in a web page.
|
|
self.app_focus = True
|
|
self.screen.refresh_bindings()
|
|
|
|
async def _on_app_blur(self, event: events.AppBlur) -> None:
|
|
"""App has lost focus."""
|
|
# Required by textual-web to manage focus in a web page.
|
|
self.app_focus = False
|
|
self.screen.refresh_bindings()
|
|
|
|
def _prune(self, *nodes: Widget, parent: DOMNode | None = None) -> AwaitRemove:
|
|
"""Prune nodes from DOM.
|
|
|
|
Args:
|
|
parent: Parent node.
|
|
|
|
Returns:
|
|
Optional awaitable.
|
|
"""
|
|
if not nodes:
|
|
return AwaitRemove([])
|
|
pruning_nodes: set[Widget] = {*nodes}
|
|
for node in nodes:
|
|
node.post_message(Prune())
|
|
pruning_nodes.update(node.walk_children(with_self=True))
|
|
|
|
try:
|
|
screen = nodes[0].screen
|
|
except (ScreenStackError, NoScreen):
|
|
pass
|
|
else:
|
|
if screen.focused and screen.focused in pruning_nodes:
|
|
screen._reset_focus(screen.focused, list(pruning_nodes))
|
|
|
|
for node in pruning_nodes:
|
|
node._pruning = True
|
|
|
|
def post_mount() -> None:
|
|
"""Called after removing children."""
|
|
|
|
if parent is not None:
|
|
try:
|
|
screen = parent.screen
|
|
except (ScreenStackError, NoScreen):
|
|
pass
|
|
else:
|
|
if screen._running:
|
|
self._update_mouse_over(screen)
|
|
finally:
|
|
parent.refresh(layout=True)
|
|
|
|
await_complete = AwaitRemove(
|
|
[task for node in nodes if (task := node._task) is not None],
|
|
post_mount,
|
|
)
|
|
self.call_next(await_complete)
|
|
return await_complete
|
|
|
|
def _watch_app_focus(self, focus: bool) -> None:
|
|
"""Respond to changes in app focus."""
|
|
self.screen._update_styles()
|
|
if focus:
|
|
# If we've got a last-focused widget, if it still has a screen,
|
|
# and if the screen is still the current screen and if nothing
|
|
# is focused right now...
|
|
try:
|
|
if (
|
|
self._last_focused_on_app_blur is not None
|
|
and self._last_focused_on_app_blur.screen is self.screen
|
|
and self.screen.focused is None
|
|
):
|
|
# ...settle focus back on that widget.
|
|
# Don't scroll the newly focused widget, as this can be quite jarring
|
|
self.screen.set_focus(
|
|
self._last_focused_on_app_blur,
|
|
scroll_visible=False,
|
|
from_app_focus=True,
|
|
)
|
|
except NoScreen:
|
|
pass
|
|
# Now that we have focus back on the app and we don't need the
|
|
# widget reference any more, don't keep it hanging around here.
|
|
self._last_focused_on_app_blur = None
|
|
else:
|
|
# Remember which widget has focus, when the app gets focus back
|
|
# we'll want to try and focus it again.
|
|
self._last_focused_on_app_blur = self.screen.focused
|
|
# Remove focus for now.
|
|
self.screen.set_focus(None)
|
|
|
|
async def action_simulate_key(self, key: str) -> None:
|
|
"""An [action](/guide/actions) to simulate a key press.
|
|
|
|
This will invoke the same actions as if the user had pressed the key.
|
|
|
|
Args:
|
|
key: The key to process.
|
|
"""
|
|
self.simulate_key(key)
|
|
|
|
async def action_quit(self) -> None:
|
|
"""An [action](/guide/actions) to quit the app as soon as possible."""
|
|
self.exit()
|
|
|
|
async def action_bell(self) -> None:
|
|
"""An [action](/guide/actions) to play the terminal 'bell'."""
|
|
self.bell()
|
|
|
|
async def action_focus(self, widget_id: str) -> None:
|
|
"""An [action](/guide/actions) to focus the given widget.
|
|
|
|
Args:
|
|
widget_id: ID of widget to focus.
|
|
"""
|
|
try:
|
|
node = self.query(f"#{widget_id}").first()
|
|
except NoMatches:
|
|
pass
|
|
else:
|
|
if isinstance(node, Widget):
|
|
self.set_focus(node)
|
|
|
|
async def action_switch_screen(self, screen: str) -> None:
|
|
"""An [action](/guide/actions) to switch screens.
|
|
|
|
Args:
|
|
screen: Name of the screen.
|
|
"""
|
|
self.switch_screen(screen)
|
|
|
|
async def action_push_screen(self, screen: str) -> None:
|
|
"""An [action](/guide/actions) to push a new screen on to the stack and make it active.
|
|
|
|
Args:
|
|
screen: Name of the screen.
|
|
"""
|
|
self.push_screen(screen)
|
|
|
|
async def action_pop_screen(self) -> None:
|
|
"""An [action](/guide/actions) to remove the topmost screen and makes the new topmost screen active."""
|
|
self.pop_screen()
|
|
|
|
async def action_switch_mode(self, mode: str) -> None:
|
|
"""An [action](/guide/actions) that switches to the given mode."""
|
|
self.switch_mode(mode)
|
|
|
|
async def action_back(self) -> None:
|
|
"""An [action](/guide/actions) to go back to the previous screen (pop the current screen).
|
|
|
|
Note:
|
|
If there is no screen to go back to, this is a non-operation (in
|
|
other words it's safe to call even if there are no other screens
|
|
on the stack.)
|
|
"""
|
|
try:
|
|
self.pop_screen()
|
|
except ScreenStackError:
|
|
pass
|
|
|
|
async def action_add_class(self, selector: str, class_name: str) -> None:
|
|
"""An [action](/guide/actions) to add a CSS class to the selected widget.
|
|
|
|
Args:
|
|
selector: Selects the widget to add the class to.
|
|
class_name: The class to add to the selected widget.
|
|
"""
|
|
self.screen.query(selector).add_class(class_name)
|
|
|
|
async def action_remove_class(self, selector: str, class_name: str) -> None:
|
|
"""An [action](/guide/actions) to remove a CSS class from the selected widget.
|
|
|
|
Args:
|
|
selector: Selects the widget to remove the class from.
|
|
class_name: The class to remove from the selected widget."""
|
|
self.screen.query(selector).remove_class(class_name)
|
|
|
|
async def action_toggle_class(self, selector: str, class_name: str) -> None:
|
|
"""An [action](/guide/actions) to toggle a CSS class on the selected widget.
|
|
|
|
Args:
|
|
selector: Selects the widget to toggle the class on.
|
|
class_name: The class to toggle on the selected widget.
|
|
"""
|
|
self.screen.query(selector).toggle_class(class_name)
|
|
|
|
def action_toggle_dark(self) -> None:
|
|
"""An [action](/guide/actions) to toggle the theme between textual-light
|
|
and textual-dark. This is offered as a convenience to simplify backwards
|
|
compatibility with previous versions of Textual which only had light mode
|
|
and dark mode."""
|
|
self.theme = (
|
|
"textual-dark" if self.theme == "textual-light" else "textual-light"
|
|
)
|
|
|
|
def action_focus_next(self) -> None:
|
|
"""An [action](/guide/actions) to focus the next widget."""
|
|
self.screen.focus_next()
|
|
|
|
def action_focus_previous(self) -> None:
|
|
"""An [action](/guide/actions) to focus the previous widget."""
|
|
self.screen.focus_previous()
|
|
|
|
def action_hide_help_panel(self) -> None:
|
|
"""Hide the keys panel (if present)."""
|
|
self.screen.query("HelpPanel").remove()
|
|
|
|
def action_show_help_panel(self) -> None:
|
|
"""Show the keys panel."""
|
|
from textual.widgets import HelpPanel
|
|
|
|
try:
|
|
self.screen.query_one(HelpPanel)
|
|
except NoMatches:
|
|
self.screen.mount(HelpPanel())
|
|
|
|
def action_notify(
|
|
self, message: str, title: str = "", severity: str = "information"
|
|
) -> None:
|
|
"""Show a notification."""
|
|
self.notify(message, title=title, severity=severity)
|
|
|
|
def _on_terminal_supports_synchronized_output(
|
|
self, message: messages.TerminalSupportsSynchronizedOutput
|
|
) -> None:
|
|
log.system("SynchronizedOutput mode is supported")
|
|
if self._driver is not None and not self._driver.is_inline:
|
|
self._sync_available = True
|
|
|
|
def _begin_update(self) -> None:
|
|
if self._sync_available and self._driver is not None:
|
|
self._driver.write(SYNC_START)
|
|
|
|
def _end_update(self) -> None:
|
|
if self._sync_available and self._driver is not None:
|
|
self._driver.write(SYNC_END)
|
|
|
|
def _refresh_notifications(self) -> None:
|
|
"""Refresh the notifications on the current screen, if one is available."""
|
|
# If we've got a screen to hand...
|
|
try:
|
|
screen = self.screen
|
|
except ScreenStackError:
|
|
pass
|
|
else:
|
|
try:
|
|
# ...see if it has a toast rack.
|
|
toast_rack = screen.get_child_by_type(ToastRack)
|
|
except NoMatches:
|
|
# It doesn't. That's fine. Either there won't ever be one,
|
|
# or one will turn up. Things will work out later.
|
|
return
|
|
# Update the toast rack.
|
|
self.call_later(toast_rack.show, self._notifications)
|
|
|
|
def clear_selection(self) -> None:
|
|
"""Clear text selection on the active screen."""
|
|
try:
|
|
self.screen.clear_selection()
|
|
except NoScreen:
|
|
pass
|
|
|
|
def notify(
|
|
self,
|
|
message: str,
|
|
*,
|
|
title: str = "",
|
|
severity: SeverityLevel = "information",
|
|
timeout: float | None = None,
|
|
markup: bool = True,
|
|
) -> None:
|
|
"""Create a notification.
|
|
|
|
!!! tip
|
|
|
|
This method is thread-safe.
|
|
|
|
|
|
Args:
|
|
message: The message for the notification.
|
|
title: The title for the notification.
|
|
severity: The severity of the notification.
|
|
timeout: The timeout (in seconds) for the notification, or `None` for default.
|
|
markup: Render the message as content markup?
|
|
|
|
The `notify` method is used to create an application-wide
|
|
notification, shown in a [`Toast`][textual.widgets._toast.Toast],
|
|
normally originating in the bottom right corner of the display.
|
|
|
|
Notifications can have the following severity levels:
|
|
|
|
- `information`
|
|
- `warning`
|
|
- `error`
|
|
|
|
The default is `information`.
|
|
|
|
Example:
|
|
```python
|
|
# Show an information notification.
|
|
self.notify("It's an older code, sir, but it checks out.")
|
|
|
|
# Show a warning. Note that Textual's notification system allows
|
|
# for the use of Rich console markup.
|
|
self.notify(
|
|
"Now witness the firepower of this fully "
|
|
"[b]ARMED[/b] and [i][b]OPERATIONAL[/b][/i] battle station!",
|
|
title="Possible trap detected",
|
|
severity="warning",
|
|
)
|
|
|
|
# Show an error. Set a longer timeout so it's noticed.
|
|
self.notify("It's a trap!", severity="error", timeout=10)
|
|
|
|
# Show an information notification, but without any sort of title.
|
|
self.notify("It's against my programming to impersonate a deity.", title="")
|
|
```
|
|
"""
|
|
if timeout is None:
|
|
timeout = self.NOTIFICATION_TIMEOUT
|
|
notification = Notification(message, title, severity, timeout, markup=markup)
|
|
self.post_message(Notify(notification))
|
|
|
|
def _on_notify(self, event: Notify) -> None:
|
|
"""Handle notification message."""
|
|
self._notifications.add(event.notification)
|
|
self._refresh_notifications()
|
|
|
|
def _unnotify(self, notification: Notification, refresh: bool = True) -> None:
|
|
"""Remove a notification from the notification collection.
|
|
|
|
Args:
|
|
notification: The notification to remove.
|
|
refresh: Flag to say if the display of notifications should be refreshed.
|
|
"""
|
|
del self._notifications[notification]
|
|
if refresh:
|
|
self._refresh_notifications()
|
|
|
|
def clear_notifications(self) -> None:
|
|
"""Clear all the current notifications."""
|
|
self._notifications.clear()
|
|
self._refresh_notifications()
|
|
|
|
def action_command_palette(self) -> None:
|
|
"""Show the Textual command palette."""
|
|
if self.use_command_palette and not CommandPalette.is_open(self):
|
|
self.push_screen(CommandPalette(id="--command-palette"))
|
|
|
|
def _suspend_signal(self) -> None:
|
|
"""Signal that the application is being suspended."""
|
|
self.app_suspend_signal.publish(self)
|
|
|
|
@on(Driver.SignalResume)
|
|
def _resume_signal(self) -> None:
|
|
"""Signal that the application is being resumed from a suspension."""
|
|
self.app_resume_signal.publish(self)
|
|
|
|
@contextmanager
|
|
def suspend(self) -> Iterator[None]:
|
|
"""A context manager that temporarily suspends the app.
|
|
|
|
While inside the `with` block, the app will stop reading input and
|
|
emitting output. Other applications will have full control of the
|
|
terminal, configured as it was before the app started running. When
|
|
the `with` block ends, the application will start reading input and
|
|
emitting output again.
|
|
|
|
Example:
|
|
```python
|
|
with self.suspend():
|
|
os.system("emacs -nw")
|
|
```
|
|
|
|
Raises:
|
|
SuspendNotSupported: If the environment doesn't support suspending.
|
|
|
|
!!! note
|
|
Suspending the application is currently only supported on
|
|
Unix-like operating systems and Microsoft Windows. Suspending is
|
|
not supported in Textual Web.
|
|
"""
|
|
if self._driver is None:
|
|
return
|
|
if self._driver.can_suspend:
|
|
# Publish a suspend signal *before* we suspend application mode.
|
|
self._suspend_signal()
|
|
self._driver.suspend_application_mode()
|
|
# We're going to handle the start of the driver again so mark
|
|
# this next part as such; the reason for this is that the code
|
|
# the developer may be running could be in this process, and on
|
|
# Unix-like systems the user may `action_suspend_process` the
|
|
# app, and we don't want to have the driver auto-restart
|
|
# application mode when the application comes back to the
|
|
# foreground, in this context.
|
|
with (
|
|
self._driver.no_automatic_restart(),
|
|
redirect_stdout(sys.__stdout__),
|
|
redirect_stderr(sys.__stderr__),
|
|
):
|
|
yield
|
|
# We're done with the dev's code so resume application mode.
|
|
self._driver.resume_application_mode()
|
|
# ...and publish a resume signal.
|
|
self._resume_signal()
|
|
self.refresh(layout=True)
|
|
else:
|
|
raise SuspendNotSupported(
|
|
"App.suspend is not supported in this environment."
|
|
)
|
|
|
|
def action_suspend_process(self) -> None:
|
|
"""Suspend the process into the background.
|
|
|
|
Note:
|
|
On Unix and Unix-like systems a `SIGTSTP` is sent to the
|
|
application's process. Currently on Windows and when running
|
|
under Textual Web this is a non-operation.
|
|
"""
|
|
# Check if we're in an environment that permits this kind of
|
|
# suspend.
|
|
if not WINDOWS and self._driver is not None and self._driver.can_suspend:
|
|
# First, ensure that the suspend signal gets published while
|
|
# we're still in application mode.
|
|
self._suspend_signal()
|
|
# With that out of the way, send the SIGTSTP signal.
|
|
os.kill(os.getpid(), signal.SIGTSTP)
|
|
# NOTE: There is no call to publish the resume signal here, this
|
|
# will be handled by the driver posting a SignalResume event
|
|
# (see the event handler on App._resume_signal) above.
|
|
|
|
def open_url(self, url: str, *, new_tab: bool = True) -> None:
|
|
"""Open a URL in the default web browser.
|
|
|
|
Args:
|
|
url: The URL to open.
|
|
new_tab: Whether to open the URL in a new tab.
|
|
"""
|
|
if self._driver is not None:
|
|
self._driver.open_url(url, new_tab)
|
|
|
|
def deliver_text(
|
|
self,
|
|
path_or_file: str | Path | TextIO,
|
|
*,
|
|
save_directory: str | Path | None = None,
|
|
save_filename: str | None = None,
|
|
open_method: Literal["browser", "download"] = "download",
|
|
encoding: str | None = None,
|
|
mime_type: str | None = None,
|
|
name: str | None = None,
|
|
) -> str | None:
|
|
"""Deliver a text file to the end-user of the application.
|
|
|
|
If a TextIO object is supplied, it will be closed by this method
|
|
and *must not be used* after this method is called.
|
|
|
|
If running in a terminal, this will save the file to the user's
|
|
downloads directory.
|
|
|
|
If running via a web browser, this will initiate a download via
|
|
a single-use URL.
|
|
|
|
After the file has been delivered, a `DeliveryComplete` message will be posted
|
|
to this `App`, which contains the `delivery_key` returned by this method. By
|
|
handling this message, you can add custom logic to your application that fires
|
|
only after the file has been delivered.
|
|
|
|
Args:
|
|
path_or_file: The path or file-like object to save.
|
|
save_directory: The directory to save the file to.
|
|
save_filename: The filename to save the file to. If `path_or_file`
|
|
is a file-like object, the filename will be generated from
|
|
the `name` attribute if available. If `path_or_file` is a path
|
|
the filename will be generated from the path.
|
|
encoding: The encoding to use when saving the file. If `None`,
|
|
the encoding will be determined by supplied file-like object
|
|
(if possible). If this is not possible, 'utf-8' will be used.
|
|
mime_type: The MIME type of the file or None to guess based on file extension.
|
|
If no MIME type is supplied and we cannot guess the MIME type, from the
|
|
file extension, the MIME type will be set to "text/plain".
|
|
name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete]
|
|
and [`DeliveryComplete`][textual.events.DeliveryComplete].
|
|
|
|
Returns:
|
|
The delivery key that uniquely identifies the file delivery.
|
|
"""
|
|
# Ensure `path_or_file` is a file-like object - convert if needed.
|
|
if isinstance(path_or_file, (str, Path)):
|
|
binary_path = Path(path_or_file)
|
|
binary = binary_path.open("rb")
|
|
file_name = save_filename or binary_path.name
|
|
else:
|
|
encoding = encoding or getattr(path_or_file, "encoding", None) or "utf-8"
|
|
binary = path_or_file
|
|
file_name = save_filename or getattr(path_or_file, "name", None)
|
|
|
|
# If we could infer a filename, and no MIME type was supplied, guess the MIME type.
|
|
if file_name and not mime_type:
|
|
mime_type, _ = mimetypes.guess_type(file_name)
|
|
|
|
# Still no MIME type? Default it to "text/plain".
|
|
if mime_type is None:
|
|
mime_type = "text/plain"
|
|
|
|
return self._deliver_binary(
|
|
binary,
|
|
save_directory=save_directory,
|
|
save_filename=file_name,
|
|
open_method=open_method,
|
|
encoding=encoding,
|
|
mime_type=mime_type,
|
|
name=name,
|
|
)
|
|
|
|
def deliver_binary(
|
|
self,
|
|
path_or_file: str | Path | BinaryIO,
|
|
*,
|
|
save_directory: str | Path | None = None,
|
|
save_filename: str | None = None,
|
|
open_method: Literal["browser", "download"] = "download",
|
|
mime_type: str | None = None,
|
|
name: str | None = None,
|
|
) -> str | None:
|
|
"""Deliver a binary file to the end-user of the application.
|
|
|
|
If an IO object is supplied, it will be closed by this method
|
|
and *must not be used* after it is supplied to this method.
|
|
|
|
If running in a terminal, this will save the file to the user's
|
|
downloads directory.
|
|
|
|
If running via a web browser, this will initiate a download via
|
|
a single-use URL.
|
|
|
|
This operation runs in a thread when running on web, so this method
|
|
returning does not indicate that the file has been delivered.
|
|
|
|
After the file has been delivered, a `DeliveryComplete` message will be posted
|
|
to this `App`, which contains the `delivery_key` returned by this method. By
|
|
handling this message, you can add custom logic to your application that fires
|
|
only after the file has been delivered.
|
|
|
|
Args:
|
|
path_or_file: The path or file-like object to save.
|
|
save_directory: The directory to save the file to. If None,
|
|
the default "downloads" directory will be used. This
|
|
argument is ignored when running via the web.
|
|
save_filename: The filename to save the file to. If None, the following logic
|
|
applies to generate the filename:
|
|
- If `path_or_file` is a file-like object, the filename will be taken from
|
|
the `name` attribute if available.
|
|
- If `path_or_file` is a path, the filename will be taken from the path.
|
|
- If a filename is not available, a filename will be generated using the
|
|
App's title and the current date and time.
|
|
open_method: The method to use to open the file. "browser" will open the file in the
|
|
web browser, "download" will initiate a download. Note that this can sometimes
|
|
be impacted by the browser's settings.
|
|
mime_type: The MIME type of the file or None to guess based on file extension.
|
|
If no MIME type is supplied and we cannot guess the MIME type, from the
|
|
file extension, the MIME type will be set to "application/octet-stream".
|
|
name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete]
|
|
and [`DeliveryComplete`][textual.events.DeliveryComplete].
|
|
|
|
Returns:
|
|
The delivery key that uniquely identifies the file delivery.
|
|
"""
|
|
# Ensure `path_or_file` is a file-like object - convert if needed.
|
|
if isinstance(path_or_file, (str, Path)):
|
|
binary_path = Path(path_or_file)
|
|
binary = binary_path.open("rb")
|
|
file_name = save_filename or binary_path.name
|
|
else: # IO object
|
|
binary = path_or_file
|
|
file_name = save_filename or getattr(path_or_file, "name", None)
|
|
|
|
# If we could infer a filename, and no MIME type was supplied, guess the MIME type.
|
|
if file_name and not mime_type:
|
|
mime_type, _ = mimetypes.guess_type(file_name)
|
|
|
|
# Still no MIME type? Default it to "application/octet-stream".
|
|
if mime_type is None:
|
|
mime_type = "application/octet-stream"
|
|
|
|
return self._deliver_binary(
|
|
binary,
|
|
save_directory=save_directory,
|
|
save_filename=file_name,
|
|
open_method=open_method,
|
|
mime_type=mime_type,
|
|
encoding=None,
|
|
name=name,
|
|
)
|
|
|
|
def _deliver_binary(
|
|
self,
|
|
binary: BinaryIO | TextIO,
|
|
*,
|
|
save_directory: str | Path | None,
|
|
save_filename: str | None,
|
|
open_method: Literal["browser", "download"],
|
|
encoding: str | None = None,
|
|
mime_type: str | None = None,
|
|
name: str | None = None,
|
|
) -> str | None:
|
|
"""Deliver a binary file to the end-user of the application."""
|
|
if self._driver is None:
|
|
return None
|
|
|
|
# Generate a filename if the file-like object doesn't have one.
|
|
if save_filename is None:
|
|
save_filename = generate_datetime_filename(self.title, "")
|
|
|
|
# Find the appropriate save location if not specified.
|
|
save_directory = (
|
|
user_downloads_path() if save_directory is None else Path(save_directory)
|
|
)
|
|
|
|
# Generate a unique key for this delivery
|
|
delivery_key = str(uuid.uuid4().hex)
|
|
|
|
# Save the file. The driver will determine the appropriate action
|
|
# to take here. It could mean simply writing to the save_path, or
|
|
# sending the file to the web browser for download.
|
|
self._driver.deliver_binary(
|
|
binary,
|
|
delivery_key=delivery_key,
|
|
save_path=save_directory / save_filename,
|
|
encoding=encoding,
|
|
open_method=open_method,
|
|
mime_type=mime_type,
|
|
name=name,
|
|
)
|
|
|
|
return delivery_key
|
|
|
|
@on(events.DeliveryComplete)
|
|
def _on_delivery_complete(self, event: events.DeliveryComplete) -> None:
|
|
"""Handle a successfully delivered screenshot."""
|
|
if event.name == "screenshot":
|
|
if event.path is None:
|
|
self.notify("Saved screenshot", title="Screenshot")
|
|
else:
|
|
self.notify(
|
|
f"Saved screenshot to [$text-success]{str(event.path)!r}",
|
|
title="Screenshot",
|
|
)
|
|
|
|
@on(events.DeliveryFailed)
|
|
def _on_delivery_failed(self, event: events.DeliveryComplete) -> None:
|
|
"""Handle a failure to deliver the screenshot."""
|
|
if event.name == "screenshot":
|
|
self.notify(
|
|
"Failed to save screenshot", title="Screenshot", severity="error"
|
|
)
|
|
|
|
@on(messages.InBandWindowResize)
|
|
def _on_in_band_window_resize(self, message: messages.InBandWindowResize) -> None:
|
|
"""In band window resize enables smooth scrolling."""
|
|
self.supports_smooth_scrolling = message.enabled
|
|
self.log.debug(message)
|
|
|
|
def _on_idle(self) -> None:
|
|
"""Send app resize events on idle, so we don't do more resizing that necessary."""
|
|
event = self._resize_event
|
|
if event is not None:
|
|
self._resize_event = None
|
|
self.screen.post_message(event)
|
|
for screen in self._background_screens:
|
|
screen.post_message(event)
|