ai-station/.venv/lib/python3.12/site-packages/textual/screen.py

2012 lines
72 KiB
Python
Raw Normal View History

2025-12-25 14:54:33 +00:00
"""
This module contains the `Screen` class and related objects.
The `Screen` class is a special widget which represents the content in the terminal. See [Screens](/guide/screens/) for details.
"""
from __future__ import annotations
import asyncio
from functools import partial
from operator import attrgetter
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
ClassVar,
Generic,
Iterable,
Iterator,
NamedTuple,
Optional,
TypeVar,
Union,
)
import rich.repr
from rich.console import RenderableType
from rich.style import Style
from textual import constants, errors, events, messages
from textual._arrange import arrange
from textual._callback import invoke
from textual._compositor import Compositor, MapGeometry
from textual._context import active_message_pump, visible_screen_stack
from textual._path import (
CSSPathType,
_css_path_type_as_list,
_make_path_object_relative,
)
from textual._spatial_map import SpatialMap
from textual._types import CallbackType
from textual.actions import SkipAction
from textual.await_complete import AwaitComplete
from textual.binding import ActiveBinding, Binding, BindingsMap
from textual.css.match import match
from textual.css.parse import parse_selectors
from textual.css.query import NoMatches, QueryType
from textual.dom import DOMNode
from textual.errors import NoWidget
from textual.geometry import NULL_OFFSET, Offset, Region, Size
from textual.keys import key_to_character
from textual.layout import DockArrangeResult
from textual.reactive import Reactive, var
from textual.renderables.background_screen import BackgroundScreen
from textual.renderables.blank import Blank
from textual.selection import SELECT_ALL, Selection
from textual.signal import Signal
from textual.timer import Timer
from textual.widget import Widget
from textual.widgets import Tooltip
from textual.widgets._toast import ToastRack
if TYPE_CHECKING:
from typing_extensions import Final
from textual.command import Provider
# Unused & ignored imports are needed for the docs to link to these objects:
from textual.message_pump import MessagePump
# Screen updates will be batched so that they don't happen more often than 60 times per second:
UPDATE_PERIOD: Final[float] = 1 / constants.MAX_FPS
ScreenResultType = TypeVar("ScreenResultType")
"""The result type of a screen."""
ScreenResultCallbackType = Union[
Callable[[Optional[ScreenResultType]], None],
Callable[[Optional[ScreenResultType]], Awaitable[None]],
]
"""Type of a screen result callback function."""
class HoverWidgets(NamedTuple):
"""Result of [get_hover_widget_at][textual.screen.Screen.get_hover_widget_at]"""
mouse_over: tuple[Widget, Region]
"""Widget and region directly under the mouse."""
hover_over: tuple[Widget, Region] | None
"""Widget with a hover style under the mouse, or `None` for no hover style widget."""
@property
def widgets(self) -> tuple[Widget, Widget | None]:
"""Just the widgets."""
return (
self.mouse_over[0],
None if self.hover_over is None else self.hover_over[0],
)
@rich.repr.auto
class ResultCallback(Generic[ScreenResultType]):
"""Holds the details of a callback."""
def __init__(
self,
requester: MessagePump,
callback: ScreenResultCallbackType[ScreenResultType] | None,
future: asyncio.Future[ScreenResultType] | None = None,
) -> None:
"""Initialise the result callback object.
Args:
requester: The object making a request for the callback.
callback: The callback function.
future: A Future to hold the result.
"""
self.requester = requester
"""The object in the DOM that requested the callback."""
self.callback: ScreenResultCallbackType | None = callback
"""The callback function."""
self.future = future
"""A future for the result"""
def __call__(self, result: ScreenResultType) -> None:
"""Call the callback, passing the given result.
Args:
result: The result to pass to the callback.
Note:
If the requested or the callback are `None` this will be a no-op.
"""
if self.future is not None:
self.future.set_result(result)
if self.requester is not None and self.callback is not None:
self.requester.call_next(self.callback, result)
self.callback = None
@rich.repr.auto
class Screen(Generic[ScreenResultType], Widget):
"""The base class for screens."""
AUTO_FOCUS: ClassVar[str | None] = None
"""A selector to determine what to focus automatically when the screen is activated.
The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors).
Set to `None` to inherit the value from the screen's app.
Set to `""` to disable auto focus.
"""
CSS: ClassVar[str] = ""
"""Inline CSS, useful for quick scripts. Rules here take priority over CSS_PATH.
Note:
This CSS applies to the whole app.
"""
CSS_PATH: ClassVar[CSSPathType | None] = None
"""File paths to load CSS from.
Note:
This CSS applies to the whole app.
"""
COMPONENT_CLASSES = {"screen--selection"}
DEFAULT_CSS = """
Screen {
layout: vertical;
overflow-y: auto;
background: $background;
&:inline {
height: auto;
min-height: 1;
border-top: tall $background;
border-bottom: tall $background;
}
&:ansi {
background: ansi_default;
color: ansi_default;
&.-screen-suspended {
text-style: dim;
ScrollBar {
text-style: not dim;
}
}
}
.screen--selection {
background: $primary 50%;
}
}
"""
TITLE: ClassVar[str | None] = None
"""A class variable to set the *default* title for the screen.
This overrides the app title.
To update the title while the screen is running,
you can set the [title][textual.screen.Screen.title] attribute.
"""
SUB_TITLE: ClassVar[str | None] = None
"""A class variable to set the *default* sub-title for the screen.
This overrides the app sub-title.
To update the sub-title while the screen is running,
you can set the [sub_title][textual.screen.Screen.sub_title] attribute.
"""
HORIZONTAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = None
"""Horizontal breakpoints, will override [App.HORIZONTAL_BREAKPOINTS][textual.app.App.HORIZONTAL_BREAKPOINTS] if not `None`."""
VERTICAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = None
"""Vertical breakpoints, will override [App.VERTICAL_BREAKPOINTS][textual.app.App.VERTICAL_BREAKPOINTS] if not `None`."""
focused: Reactive[Widget | None] = Reactive(None)
"""The focused [widget][textual.widget.Widget] or `None` for no focus.
To set focus, do not update this value directly. Use [set_focus][textual.screen.Screen.set_focus] instead."""
stack_updates: Reactive[int] = Reactive(0, repaint=False)
"""An integer that updates when the screen is resumed."""
sub_title: Reactive[str | None] = Reactive(None, compute=False)
"""Screen sub-title to override [the app sub-title][textual.app.App.sub_title]."""
title: Reactive[str | None] = Reactive(None, compute=False)
"""Screen title to override [the app title][textual.app.App.title]."""
COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = set()
"""Command providers used by the [command palette](/guide/command_palette), associated with the screen.
Should be a set of [`command.Provider`][textual.command.Provider] classes.
"""
ALLOW_IN_MAXIMIZED_VIEW: ClassVar[str | None] = None
"""A selector for the widgets (direct children of Screen) that are allowed in the maximized view (in addition to maximized widget). Or
`None` to default to [App.ALLOW_IN_MAXIMIZED_VIEW][textual.app.App.ALLOW_IN_MAXIMIZED_VIEW]"""
ESCAPE_TO_MINIMIZE: ClassVar[bool | None] = None
"""Use escape key to minimize (potentially overriding bindings) or `None` to defer to [`App.ESCAPE_TO_MINIMIZE`][textual.app.App.ESCAPE_TO_MINIMIZE]."""
maximized: Reactive[Widget | None] = Reactive(None, layout=True)
"""The currently maximized widget, or `None` for no maximized widget."""
selections: var[dict[Widget, Selection]] = var(dict)
"""Map of widgets and selected ranges."""
_selecting = var(False)
"""Indicates mouse selection is in progress."""
_box_select = var(False)
"""Should text selection be limited to a box?"""
_select_start: Reactive[tuple[Widget, Offset, Offset] | None] = Reactive(None)
"""Tuple of (widget, screen offset, text offset) where selection started."""
_select_end: Reactive[tuple[Widget, Offset, Offset] | None] = Reactive(None)
"""Tuple of (widget, screen offset, text offset) where selection ends."""
_mouse_down_offset: var[Offset | None] = var(None)
"""Last mouse down screen offset, or `None` if the mouse is up."""
BINDINGS = [
Binding("tab", "app.focus_next", "Focus Next", show=False),
Binding("shift+tab", "app.focus_previous", "Focus Previous", show=False),
Binding("ctrl+c", "screen.copy_text", "Copy selected text", show=False),
]
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
"""
Initialize the screen.
Args:
name: The name of the screen.
id: The ID of the screen in the DOM.
classes: The CSS classes for the screen.
"""
self._modal = False
super().__init__(name=name, id=id, classes=classes)
self._compositor = Compositor()
self._dirty_widgets: set[Widget] = set()
self.__update_timer: Timer | None = None
self._callbacks: list[tuple[CallbackType, MessagePump]] = []
self._result_callbacks: list[ResultCallback[ScreenResultType | None]] = []
self._tooltip_widget: Widget | None = None
self._tooltip_timer: Timer | None = None
css_paths = [
_make_path_object_relative(css_path, self)
for css_path in (
_css_path_type_as_list(self.CSS_PATH)
if self.CSS_PATH is not None
else []
)
]
self.css_path = css_paths
self.title = self.TITLE
self.sub_title = self.SUB_TITLE
self.screen_layout_refresh_signal: Signal[Screen] = Signal(
self, "layout-refresh"
)
"""The signal that is published when the screen's layout is refreshed."""
self.bindings_updated_signal: Signal[Screen] = Signal(self, "bindings_updated")
"""A signal published when the bindings have been updated"""
self.text_selection_started_signal: Signal[Screen] = Signal(
self, "selection_started"
)
"""A signal published when text selection has started."""
self._css_update_count = -1
"""Track updates to CSS."""
self._layout_widgets: dict[DOMNode, set[Widget]] = {}
"""Widgets whose layout may have changed."""
@property
def is_modal(self) -> bool:
"""Is the screen modal?"""
return self._modal
@property
def is_current(self) -> bool:
"""Is the screen current (i.e. visible to user)?"""
from textual.app import ScreenStackError
try:
return self.app.screen is self or self in self.app._background_screens
except ScreenStackError:
return False
@property
def _update_timer(self) -> Timer:
"""Timer used to perform updates."""
if self.__update_timer is None:
self.__update_timer = self.set_interval(
UPDATE_PERIOD, self._on_timer_update, name="screen_update", pause=True
)
return self.__update_timer
@property
def layers(self) -> tuple[str, ...]:
"""Layers from parent.
Returns:
Tuple of layer names.
"""
extras = ["_loading"]
if not self.app._disable_notifications:
extras.append("_toastrack")
if not self.app._disable_tooltips:
extras.append("_tooltips")
return (*super().layers, *extras)
@property
def size(self) -> Size:
"""The size of the screen."""
return self.app.size - self.styles.gutter.totals
def _watch_focused(self):
self.refresh_bindings()
def _watch_stack_updates(self):
self.refresh_bindings()
async def _watch_selections(
self,
old_selections: dict[Widget, Selection],
selections: dict[Widget, Selection],
):
for widget in old_selections.keys() | selections.keys():
widget.selection_updated(selections.get(widget, None))
def refresh_bindings(self) -> None:
"""Call to request a refresh of bindings."""
self.bindings_updated_signal.publish(self)
def _watch_maximized(
self, previously_maximized: Widget | None, maximized: Widget | None
) -> None:
# The screen gets a `-maximized-view` class if there is a maximized widget
# The widget gets a `-maximized` class if it is maximized
self.set_class(maximized is not None, "-maximized-view")
if previously_maximized is not None:
previously_maximized.remove_class("-maximized")
if maximized is not None:
maximized.add_class("-maximized")
@property
def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
"""Binding chain from this screen."""
focused = self.focused
if focused is not None and focused.loading:
focused = None
namespace_bindings: list[tuple[DOMNode, BindingsMap]]
if focused is None:
namespace_bindings = [
(self, self._bindings.copy()),
(self.app, self.app._bindings.copy()),
]
else:
namespace_bindings = [
(node, node._bindings.copy()) for node in focused.ancestors_with_self
]
# Filter out bindings that could be captures by widgets (such as Input, TextArea)
filter_namespaces: list[DOMNode] = []
for namespace, bindings_map in namespace_bindings:
for filter_namespace in filter_namespaces:
check_consume_key = filter_namespace.check_consume_key
for key in list(bindings_map.key_to_bindings):
if check_consume_key(key, key_to_character(key)):
# If the widget consumes the key (e.g. like an Input widget),
# then remove the key from the bindings map.
del bindings_map.key_to_bindings[key]
filter_namespaces.append(namespace)
keymap = self.app._keymap
for namespace, bindings_map in namespace_bindings:
if keymap:
result = bindings_map.apply_keymap(keymap)
if result.clashed_bindings:
self.app.handle_bindings_clash(result.clashed_bindings, namespace)
return namespace_bindings
@property
def _modal_binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
"""The binding chain, ignoring everything before the last modal."""
binding_chain = self._binding_chain
for index, (node, _bindings) in enumerate(binding_chain, 1):
if node.is_modal:
return binding_chain[:index]
return binding_chain
@property
def active_bindings(self) -> dict[str, ActiveBinding]:
"""Get currently active bindings for this screen.
If no widget is focused, then app-level bindings are returned.
If a widget is focused, then any bindings present in the screen and app are merged and returned.
This property may be used to inspect current bindings.
Returns:
A map of keys to a tuple containing (NAMESPACE, BINDING, ENABLED).
"""
bindings_map: dict[str, ActiveBinding] = {}
app = self.app
for namespace, bindings in self._modal_binding_chain:
for key, binding in bindings:
# This will call the nodes `check_action` method.
action_state = app._check_action_state(binding.action, namespace)
if action_state is False:
# An action_state of False indicates the action is disabled and not shown
# Note that None has a different meaning, which is why there is an `is False`
# rather than a truthy check.
continue
enabled = bool(action_state)
if existing_key_and_binding := bindings_map.get(key):
# This key has already been bound
# Replace priority bindings
if (
binding.priority
and not existing_key_and_binding.binding.priority
):
bindings_map[key] = ActiveBinding(
namespace, binding, enabled, binding.tooltip
)
else:
# New binding
bindings_map[key] = ActiveBinding(
namespace, binding, enabled, binding.tooltip
)
return bindings_map
def arrange(self, size: Size, _optimal: bool = False) -> DockArrangeResult:
"""Arrange children.
Args:
size: Size of container.
optimal: Ignored on screen.
Returns:
Widget locations.
"""
# This is customized over the base class to allow for a widget to be maximized
cache_key = (size, self._nodes._updates, self.maximized)
cached_result = self._arrangement_cache.get(cache_key)
if cached_result is not None:
return cached_result
allow_in_maximized_view = (
self.app.ALLOW_IN_MAXIMIZED_VIEW
if self.ALLOW_IN_MAXIMIZED_VIEW is None
else self.ALLOW_IN_MAXIMIZED_VIEW
)
def get_maximize_widgets(maximized: Widget) -> list[Widget]:
"""Get widgets to display in maximized view.
Returns:
A list of widgets.
"""
# De-duplicate with a set
widgets = {
maximized,
*self.query_children(allow_in_maximized_view),
*self.query_children(".-textual-system"),
}
# Restore order of widgets.
maximize_widgets = [widget for widget in self.children if widget in widgets]
# Add the maximized widget, if its not already included
if maximized not in maximize_widgets:
maximize_widgets.insert(0, maximized)
return maximize_widgets
arrangement = self._arrangement_cache[cache_key] = arrange(
self,
(
get_maximize_widgets(self.maximized)
if self.maximized is not None
else self._nodes
),
size,
self.size,
False,
)
return arrangement
@property
def is_active(self) -> bool:
"""Is the screen active (i.e. visible and top of the stack)?"""
try:
return self.app.screen is self
except Exception:
return False
@property
def allow_select(self) -> bool:
"""Check if this widget permits text selection."""
return self.ALLOW_SELECT
def get_loading_widget(self) -> Widget:
"""Get a widget to display a loading indicator.
The default implementation will defer to App.get_loading_widget.
Returns:
A widget in place of this widget to indicate a loading.
"""
loading_widget = self.app.get_loading_widget()
return loading_widget
def render(self) -> RenderableType:
"""Render method inherited from widget, used to render the screen's background.
Returns:
Background renderable.
"""
background = self.styles.background
try:
base_screen = visible_screen_stack.get().pop()
except LookupError:
base_screen = None
if base_screen is not None and background.a < 1:
# If background is translucent, render a background screen
return BackgroundScreen(base_screen, background)
if background.is_transparent:
# If the background is transparent, defer to App.render
return self.app.render()
# Render a screen of a solid color.
return Blank(background)
def get_offset(self, widget: Widget) -> Offset:
"""Get the absolute offset of a given Widget.
Args:
widget: A widget
Returns:
The widget's offset relative to the top left of the terminal.
"""
return self._compositor.get_offset(widget)
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
"""Get the widget at a given coordinate.
Args:
x: X Coordinate.
y: Y Coordinate.
Returns:
Widget and screen region.
Raises:
NoWidget: If there is no widget under the screen coordinate.
"""
return self._compositor.get_widget_at(x, y)
def get_hover_widgets_at(self, x: int, y: int) -> HoverWidgets:
"""Get the widget, and its region directly under the mouse, and the first
widget, region pair with a hover style.
Args:
x: X Coordinate.
y: Y Coordinate.
Returns:
A pair of (WIDGET, REGION) tuples for the top most and first hover style respectively.
Raises:
NoWidget: If there is no widget under the screen coordinate.
"""
widgets_under_coordinate = iter(self._compositor.get_widgets_at(x, y))
try:
top_widget, top_region = next(widgets_under_coordinate)
except StopIteration:
raise errors.NoWidget(f"No hover widget under screen coordinate ({x}, {y})")
if not top_widget._has_hover_style:
for widget, region in widgets_under_coordinate:
if widget._has_hover_style:
return HoverWidgets((top_widget, top_region), (widget, region))
return HoverWidgets((top_widget, top_region), None)
return HoverWidgets((top_widget, top_region), (top_widget, top_region))
def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
"""Get all widgets under a given coordinate.
Args:
x: X coordinate.
y: Y coordinate.
Returns:
Sequence of (WIDGET, REGION) tuples.
"""
return self._compositor.get_widgets_at(x, y)
def get_focusable_widget_at(self, x: int, y: int) -> Widget | None:
"""Get the focusable widget under a given coordinate.
If the widget directly under the given coordinate is not focusable, then this method will check
if any of the ancestors are focusable. If no ancestors are focusable, then `None` will be returned.
Args:
x: X coordinate.
y: Y coordinate.
Returns:
A `Widget`, or `None` if there is no focusable widget underneath the coordinate.
"""
try:
widget, _region = self.get_widget_at(x, y)
except NoWidget:
return None
if widget.has_class("-textual-system") or widget.loading:
# Clicking Textual system widgets should not focus anything
return None
for node in widget.ancestors_with_self:
if isinstance(node, Widget) and node.focusable:
return node
return None
def get_style_at(self, x: int, y: int) -> Style:
"""Get the style under a given coordinate.
Args:
x: X Coordinate.
y: Y Coordinate.
Returns:
Rich Style object.
"""
return self._compositor.get_style_at(x, y)
def get_widget_and_offset_at(
self, x: int, y: int
) -> tuple[Widget | None, Offset | None]:
"""Get the widget under a given coordinate, and an offset within the original content.
Args:
x: X Coordinate.
y: Y Coordinate.
Returns:
Tuple of Widget and Offset, both of which may be None.
"""
return self._compositor.get_widget_and_offset_at(x, y)
def find_widget(self, widget: Widget) -> MapGeometry:
"""Get the screen region of a Widget.
Args:
widget: A Widget within the composition.
Returns:
Region relative to screen.
Raises:
NoWidget: If the widget could not be found in this screen.
"""
return self._compositor.find_widget(widget)
def clear_selection(self) -> None:
"""Clear any selected text."""
self.selections = {}
self._select_start = None
self._select_end = None
def _select_all_in_widget(self, widget: Widget) -> None:
"""Select a widget and all its children.
Args:
widget: Widget to select.
"""
select_all = SELECT_ALL
self.selections = {
widget: select_all,
**{child: select_all for child in widget.query("*")},
}
@property
def focus_chain(self) -> list[Widget]:
"""A list of widgets that may receive focus, in focus order."""
# TODO: Calculating a focus chain is moderately expensive.
# Suspect we can move focus without calculating the entire thing again.
widgets: list[Widget] = []
add_widget = widgets.append
focus_sorter = attrgetter("_focus_sort_key")
# We traverse the DOM and keep track of where we are at with a node stack.
# Additionally, we manually keep track of the visibility of the DOM
# instead of relying on the property `.visible` to save on DOM traversals.
# node_stack: list[tuple[iterator over node children, node visibility]]
root_node = self.screen
if (focused := self.focused) is not None:
for node in focused.ancestors_with_self:
if node._trap_focus:
root_node = node
break
node_stack: list[tuple[Iterator[Widget], bool]] = [
(
iter(sorted(root_node.displayed_children, key=focus_sorter)),
self.visible,
)
]
pop = node_stack.pop
push = node_stack.append
while node_stack:
children_iterator, parent_visibility = node_stack[-1]
node = next(children_iterator, None)
if node is None:
pop()
else:
if node._check_disabled():
continue
node_styles_visibility = node.styles.get_rule("visibility")
node_is_visible = (
node_styles_visibility != "hidden"
if node_styles_visibility
else parent_visibility # Inherit visibility if the style is unset.
)
if node.is_container and node.allow_focus_children():
sorted_displayed_children = sorted(
node.displayed_children, key=focus_sorter
)
push((iter(sorted_displayed_children), node_is_visible))
# Same check as `if node.focusable`, but we cached inherited visibility
# and we also skipped disabled nodes altogether.
if node_is_visible and node.allow_focus():
add_widget(node)
return widgets
def _move_focus(
self, direction: int = 0, selector: str | type[QueryType] = "*"
) -> Widget | None:
"""Move the focus in the given direction.
If no widget is currently focused, this will focus the first focusable widget.
If no focusable widget matches the given CSS selector, focus is set to `None`.
Args:
direction: 1 to move forward, -1 to move backward, or
0 to keep the current focus.
selector: CSS selector to filter
what nodes can be focused.
Returns:
Newly focused widget, or None for no focus. If the return
is not `None`, then it is guaranteed that the widget returned matches
the CSS selectors given in the argument.
"""
if not isinstance(selector, str):
selector = selector.__name__
selector_set = parse_selectors(selector)
focus_chain = self.focus_chain
# If a widget is maximized we want to limit the focus chain to the visible widgets
if self.maximized is not None:
focusable = set(self.maximized.walk_children(with_self=True))
focus_chain = [widget for widget in focus_chain if widget in focusable]
filtered_focus_chain = (
node for node in focus_chain if match(selector_set, node)
)
if not focus_chain:
# Nothing focusable, so nothing to do
return self.focused
if self.focused is None:
# Nothing currently focused, so focus the first one.
to_focus = next(filtered_focus_chain, None)
self.set_focus(to_focus)
return self.focused
# Ensure focus will be in a node that matches the selectors.
if not direction and not match(selector_set, self.focused):
direction = 1
try:
# Find the index of the currently focused widget
current_index = focus_chain.index(self.focused)
except ValueError:
# Focused widget was removed in the interim, start again
self.set_focus(next(filtered_focus_chain, None))
else:
# Only move the focus if we are currently showing the focus
if direction:
to_focus = None
chain_length = len(focus_chain)
for step in range(1, len(focus_chain) + 1):
node = focus_chain[
(current_index + direction * step) % chain_length
]
if match(selector_set, node):
to_focus = node
break
self.set_focus(to_focus)
return self.focused
def focus_next(self, selector: str | type[QueryType] = "*") -> Widget | None:
"""Focus the next widget, optionally filtered by a CSS selector.
If no widget is currently focused, this will focus the first focusable widget.
If no focusable widget matches the given CSS selector, focus is set to `None`.
Args:
selector: CSS selector to filter
what nodes can be focused.
Returns:
Newly focused widget, or None for no focus. If the return
is not `None`, then it is guaranteed that the widget returned matches
the CSS selectors given in the argument.
"""
return self._move_focus(1, selector)
def focus_previous(self, selector: str | type[QueryType] = "*") -> Widget | None:
"""Focus the previous widget, optionally filtered by a CSS selector.
If no widget is currently focused, this will focus the first focusable widget.
If no focusable widget matches the given CSS selector, focus is set to `None`.
Args:
selector: CSS selector to filter
what nodes can be focused.
Returns:
Newly focused widget, or None for no focus. If the return
is not `None`, then it is guaranteed that the widget returned matches
the CSS selectors given in the argument.
"""
return self._move_focus(-1, selector)
def maximize(self, widget: Widget, container: bool = True) -> bool:
"""Maximize a widget, so it fills the screen.
Args:
widget: Widget to maximize.
container: If one of the widgets ancestors is a maximizeable widget, maximize that instead.
Returns:
`True` if the widget was maximized, otherwise `False`.
"""
if widget.allow_maximize:
if container:
# If we want to maximize the container, look up the dom to find a suitable widget
for maximize_widget in widget.ancestors:
if not isinstance(maximize_widget, Widget):
break
if maximize_widget.allow_maximize:
self.maximized = maximize_widget
return True
self.maximized = widget
return True
return False
def minimize(self) -> None:
"""Restore any maximized widget to normal state."""
self.maximized = None
if self.focused is not None:
self.call_after_refresh(
self.scroll_to_widget, self.focused, animate=False, center=True
)
def get_selected_text(self) -> str | None:
"""Get text under selection.
Returns:
Selected text, or `None` if no text was selected.
"""
if not self.selections:
return None
widget_text: list[str] = []
for widget, selection in self.selections.items():
selected_text_in_widget = widget.get_selection(selection)
if selected_text_in_widget is not None:
widget_text.extend(selected_text_in_widget)
selected_text = "".join(widget_text).rstrip("\n")
return selected_text
def action_copy_text(self) -> None:
"""Copy selected text to clipboard."""
selection = self.get_selected_text()
if selection is None:
# No text selected
raise SkipAction()
self.app.copy_to_clipboard(selection)
def action_maximize(self) -> None:
"""Action to maximize the currently focused widget."""
if self.focused is not None:
self.maximize(self.focused)
def action_minimize(self) -> None:
"""Action to minimize the currently maximized widget."""
self.minimize()
def action_blur(self) -> None:
"""Action to remove focus (if set)."""
self.set_focus(None)
async def action_focus(self, selector: str) -> None:
"""An [action](/guide/actions) to focus the given widget.
Args:
selector: Selector of widget to focus (first match).
"""
try:
node = self.query(selector).first()
except NoMatches:
pass
else:
if isinstance(node, Widget):
self.set_focus(node)
def _reset_focus(
self, widget: Widget, avoiding: list[Widget] | None = None
) -> None:
"""Reset the focus when a widget is removed
Args:
widget: A widget that is removed.
avoiding: Optional list of nodes to avoid.
"""
avoiding = avoiding or []
# Make this a NOP if we're being asked to deal with a widget that
# isn't actually the currently-focused widget.
if self.focused is not widget:
return
# Grab the list of widgets that we can set focus to.
focusable_widgets = self.focus_chain
if not focusable_widgets:
# If there's nothing to focus... give up now.
self.set_focus(None)
return
try:
# Find the location of the widget we're taking focus from, in
# the focus chain.
widget_index = focusable_widgets.index(widget)
except ValueError:
# widget is not in focusable widgets
# It may have been made invisible
# Move to a sibling if possible
for sibling in widget.visible_siblings:
if sibling not in avoiding and sibling.focusable:
self.set_focus(sibling)
break
else:
self.set_focus(None)
return
# Now go looking for something before it, that isn't about to be
# removed, and which can receive focus, and go focus that.
chosen: Widget | None = None
for candidate in reversed(
focusable_widgets[widget_index + 1 :] + focusable_widgets[:widget_index]
):
if candidate not in avoiding:
chosen = candidate
break
# Go with what was found.
self.set_focus(chosen)
def _update_focus_styles(
self, focused: Widget | None = None, blurred: Widget | None = None
) -> None:
"""Update CSS for focus changes.
Args:
focused: The widget that was focused.
blurred: The widget that was blurred.
"""
widgets: set[DOMNode] = set()
if focused is not None:
for widget in reversed(focused.ancestors_with_self):
if widget._has_focus_within:
widgets.update(widget.walk_children(with_self=True))
break
if blurred is not None:
for widget in reversed(blurred.ancestors_with_self):
if widget._has_focus_within:
widgets.update(widget.walk_children(with_self=True))
break
if widgets:
self.app.stylesheet.update_nodes(widgets, animate=True)
def set_focus(
self,
widget: Widget | None,
scroll_visible: bool = True,
from_app_focus: bool = False,
) -> None:
"""Focus (or un-focus) a widget. A focused widget will receive key events first.
Args:
widget: Widget to focus, or None to un-focus.
scroll_visible: Scroll widget into view.
from_app_focus: True if this focus is due to the app itself having regained
focus. False if the focus is being set because a widget within the app
regained focus.
"""
if widget is self.focused:
# Widget is already focused
return
focused: Widget | None = None
blurred: Widget | None = None
if widget is None:
# No focus, so blur currently focused widget if it exists
if self.focused is not None:
self.focused.post_message(events.Blur())
blurred = self.focused
self.focused = None
self.log.debug("focus was removed")
elif widget.focusable:
if self.focused != widget:
if self.focused is not None:
# Blur currently focused widget
self.focused.post_message(events.Blur())
blurred = self.focused
# Change focus
self.focused = widget
# Send focus event
widget.post_message(events.Focus(from_app_focus=from_app_focus))
focused = widget
if scroll_visible:
def scroll_to_center(widget: Widget) -> None:
"""Scroll to center (after a refresh)."""
if self.focused is widget and not self.can_view_entire(widget):
self.scroll_to_center(widget, origin_visible=True)
self.call_later(scroll_to_center, widget)
self.log.debug(widget, "was focused")
self._update_focus_styles(focused, blurred)
self.call_after_refresh(self.refresh_bindings)
def _extend_compose(self, widgets: list[Widget]) -> None:
"""Insert Textual's own internal widgets.
Args:
widgets: The list of widgets to be composed.
This method adds the tooltip, if required, and also adds the
container for `Toast`s.
"""
if not self.app._disable_tooltips:
widgets.insert(0, Tooltip(id="textual-tooltip"))
if not self.app._disable_notifications:
widgets.insert(0, ToastRack(id="textual-toastrack"))
def _on_mount(self, event: events.Mount) -> None:
"""Set up the tooltip-clearing signal when we mount."""
self.screen_layout_refresh_signal.subscribe(
self, self._maybe_clear_tooltip, immediate=True
)
async def _on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()
if not self.app._batch_count and self.is_current:
if (
self._layout_required
or self._scroll_required
or self._repaint_required
or self._recompose_required
or self._dirty_widgets
):
self._update_timer.resume()
return
await self._invoke_and_clear_callbacks()
def _compositor_refresh(self) -> None:
"""Perform a compositor refresh."""
app = self.app
if app.is_inline:
if self is app.screen:
inline_height = app._get_inline_height()
clear = (
app._previous_inline_height is not None
and inline_height < app._previous_inline_height
)
app._display(
self,
self._compositor.render_inline(
app.size.with_height(inline_height),
screen_stack=app._background_screens,
clear=clear,
),
)
app._previous_inline_height = inline_height
self._dirty_widgets.clear()
self._compositor._dirty_regions.clear()
elif (
self in self.app._background_screens and self._compositor._dirty_regions
):
app.screen.refresh(*self._compositor._dirty_regions)
self._compositor._dirty_regions.clear()
self._dirty_widgets.clear()
else:
if self is app.screen:
# Top screen
update = self._compositor.render_update(
screen_stack=app._background_screens
)
app._display(self, update)
self._dirty_widgets.clear()
elif (
self in self.app._background_screens and self._compositor._dirty_regions
):
self._set_dirty(*self._compositor._dirty_regions)
app.screen.refresh(*self._compositor._dirty_regions)
self._repaint_required = True
self._compositor._dirty_regions.clear()
self._dirty_widgets.clear()
app._update_mouse_over(self)
def _on_timer_update(self) -> None:
"""Called by the _update_timer."""
self._update_timer.pause()
if self.is_current and not self.app._batch_count:
if self._layout_required:
self._refresh_layout(scroll=self._scroll_required)
self._layout_required = False
self._dirty_widgets.clear()
elif self._scroll_required:
self._refresh_layout(scroll=True)
self._scroll_required = False
if self._repaint_required:
self._dirty_widgets.clear()
self._dirty_widgets.add(self)
self._repaint_required = False
if self._dirty_widgets:
self._compositor.update_widgets(self._dirty_widgets)
self._compositor_refresh()
if self._recompose_required:
self._recompose_required = False
self.call_next(self.recompose)
if self._callbacks:
self.call_next(self._invoke_and_clear_callbacks)
async def _invoke_and_clear_callbacks(self) -> None:
"""If there are scheduled callbacks to run, call them and clear
the callback queue."""
if self._callbacks:
callbacks = self._callbacks[:]
self._callbacks.clear()
for callback, message_pump in callbacks:
with message_pump._context():
await invoke(callback)
def _invoke_later(self, callback: CallbackType, sender: MessagePump) -> None:
"""Enqueue a callback to be invoked after the screen is repainted.
Args:
callback: A callback.
sender: The sender (active message pump) of the callback.
"""
self._callbacks.append((callback, sender))
self.check_idle()
def _push_result_callback(
self,
requester: MessagePump,
callback: ScreenResultCallbackType[ScreenResultType] | None,
future: asyncio.Future[ScreenResultType | None] | None = None,
) -> None:
"""Add a result callback to the screen.
Args:
requester: The object requesting the callback.
callback: The callback.
future: A Future to hold the result.
"""
self._result_callbacks.append(
ResultCallback[Optional[ScreenResultType]](requester, callback, future)
)
async def _message_loop_exit(self) -> None:
await super()._message_loop_exit()
self._compositor.clear()
self._dirty_widgets.clear()
self._dirty_regions.clear()
self._arrangement_cache.clear()
self.screen_layout_refresh_signal.unsubscribe(self)
self._nodes._clear()
self._task = None
def _pop_result_callback(self) -> None:
"""Remove the latest result callback from the stack."""
self._result_callbacks.pop()
def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> None:
"""Refresh the layout (can change size and positions of widgets)."""
size = self.outer_size if size is None else size
if self.app.is_inline:
size = size.with_height(self.app._get_inline_height())
if not size:
return
self._compositor.update_widgets(self._dirty_widgets)
self._update_timer.pause()
ResizeEvent = events.Resize
try:
if scroll and not self._layout_widgets:
exposed_widgets = self._compositor.reflow_visible(self, size)
if exposed_widgets:
layers = self._compositor.layers
for widget, (
region,
_order,
_clip,
virtual_size,
container_size,
_,
_,
) in layers:
if widget in exposed_widgets:
if widget._size_updated(
region.size, virtual_size, container_size, layout=False
):
widget.post_message(
ResizeEvent(
region.size, virtual_size, container_size
)
)
else:
hidden, shown, resized = self._compositor.reflow(self, size)
self._layout_widgets.clear()
Hide = events.Hide
Show = events.Show
for widget in hidden:
widget.post_message(Hide())
# We want to send a resize event to widgets that were just added or change since last layout
send_resize = shown | resized
layers = self._compositor.layers
for widget, (
region,
_order,
_clip,
virtual_size,
container_size,
_,
_,
) in layers:
widget._size_updated(region.size, virtual_size, container_size)
if widget in send_resize:
widget.post_message(
ResizeEvent(region.size, virtual_size, container_size)
)
for widget in shown:
widget.post_message(Show())
except Exception as error:
self.app._handle_exception(error)
return
if self.is_current:
self._compositor_refresh()
if self.app._dom_ready:
self.screen_layout_refresh_signal.publish(self.screen)
else:
self.app.post_message(events.Ready())
self.app._dom_ready = True
async def _on_update(self, message: messages.Update) -> None:
message.stop()
message.prevent_default()
widget = message.widget
assert isinstance(widget, Widget)
if self in self._compositor:
self._dirty_widgets.add(widget)
self.check_idle()
async def _on_layout(self, message: messages.Layout) -> None:
message.stop()
message.prevent_default()
layout_required = False
widget: DOMNode = message.widget
for ancestor in message.widget.ancestors:
if not isinstance(ancestor, Widget):
break
if ancestor not in self._layout_widgets:
self._layout_widgets[ancestor] = set()
if widget not in self._layout_widgets:
self._layout_widgets[ancestor].add(widget)
layout_required = True
if not ancestor.styles.auto_dimensions:
break
widget = ancestor
if layout_required and not self._layout_required:
self._layout_required = True
self.check_idle()
async def _on_update_scroll(self, message: messages.UpdateScroll) -> None:
message.stop()
message.prevent_default()
self._scroll_required = True
self.check_idle()
def _get_inline_height(self, size: Size) -> int:
"""Get the inline height (number of lines to display when running inline mode).
Args:
size: Size of the terminal
Returns:
Height for inline mode.
"""
height_scalar = self.styles.height
if height_scalar is None or height_scalar.is_auto:
inline_height = self.get_content_height(size, size, size.width)
else:
inline_height = int(height_scalar.resolve(size, size))
inline_height += self.styles.gutter.height
min_height = self.styles.min_height
max_height = self.styles.max_height
if min_height is not None:
inline_height = max(inline_height, int(min_height.resolve(size, size)))
if max_height is not None:
inline_height = min(inline_height, int(max_height.resolve(size, size)))
inline_height = min(self.app.size.height, inline_height)
return inline_height
def _screen_resized(self, size: Size) -> None:
"""Called by App when the screen is resized."""
if self.stack_updates and self.is_attached:
self._refresh_layout(size)
def _on_screen_resume(self) -> None:
"""Screen has resumed."""
if self.app.SUSPENDED_SCREEN_CLASS:
self.remove_class(self.app.SUSPENDED_SCREEN_CLASS)
self.stack_updates += 1
self.app._refresh_notifications()
size = self.app.size
self._update_auto_focus()
if self.is_attached:
self._compositor_refresh()
self.app.stylesheet.update(self)
self._refresh_layout(size)
self.refresh()
async def _compose(self) -> None:
await super()._compose()
self._update_auto_focus()
def _update_auto_focus(self) -> None:
"""Update auto focus."""
if self.app.app_focus:
auto_focus = (
self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS
)
if auto_focus and self.focused is None:
for widget in self.query(auto_focus):
if widget.focusable:
widget.has_focus = True
self.set_focus(widget)
break
def _on_screen_suspend(self) -> None:
"""Screen has suspended."""
if self.app.SUSPENDED_SCREEN_CLASS:
self.add_class(self.app.SUSPENDED_SCREEN_CLASS)
self.app._set_mouse_over(None, None)
self._clear_tooltip()
self.stack_updates += 1
async def _on_resize(self, event: events.Resize) -> None:
event.stop()
self._screen_resized(event.size)
for screen in self.app._background_screens:
screen._screen_resized(event.size)
horizontal_breakpoints = (
self.app.HORIZONTAL_BREAKPOINTS
if self.HORIZONTAL_BREAKPOINTS is None
else self.HORIZONTAL_BREAKPOINTS
) or []
vertical_breakpoints = (
self.app.VERTICAL_BREAKPOINTS
if self.VERTICAL_BREAKPOINTS is None
else self.VERTICAL_BREAKPOINTS
) or []
width, height = event.size
if horizontal_breakpoints:
self._set_breakpoints(width, horizontal_breakpoints)
if vertical_breakpoints:
self._set_breakpoints(height, vertical_breakpoints)
def _set_breakpoints(
self, dimension: int, breakpoints: list[tuple[int, str]]
) -> None:
"""Set horizontal or vertical breakpoints.
Args:
dimension: Either the width or the height.
breakpoints: A list of breakpoints.
"""
class_names = [class_name for _breakpoint, class_name in breakpoints]
self.remove_class(*class_names)
for breakpoint, class_name in sorted(breakpoints, reverse=True):
if dimension >= breakpoint:
self.add_class(class_name)
return
def _update_tooltip(self, widget: Widget) -> None:
"""Update the content of the tooltip."""
try:
tooltip = self.get_child_by_type(Tooltip)
except NoMatches:
pass
else:
if tooltip.display and self._tooltip_widget is widget:
self._handle_tooltip_timer(widget)
def _clear_tooltip(self) -> None:
"""Unconditionally clear any existing tooltip."""
try:
tooltip = self.get_child_by_type(Tooltip)
except NoMatches:
return
if tooltip.display:
if self._tooltip_timer is not None:
self._tooltip_timer.stop()
tooltip.display = False
def _maybe_clear_tooltip(self, _) -> None:
"""Check if the widget under the mouse cursor still pertains to the tooltip.
If they differ, the tooltip will be removed.
"""
# If there's a widget associated with the tooltip at all...
if self._tooltip_widget is not None:
# ...look at what's currently under the mouse.
try:
under_mouse, _ = self.get_widget_at(*self.app.mouse_position)
except NoWidget:
pass
else:
# If it's not the same widget...
if under_mouse is not self._tooltip_widget:
# ...clear the tooltip.
self._clear_tooltip()
def _handle_tooltip_timer(self, widget: Widget) -> None:
"""Called by a timer from _handle_mouse_move to update the tooltip.
Args:
widget: The widget under the mouse.
"""
try:
tooltip = self.get_child_by_type(Tooltip)
except NoMatches:
pass
else:
tooltip_content: RenderableType | None = None
for node in widget.ancestors_with_self:
if not isinstance(node, Widget):
break
if node.tooltip is not None:
tooltip_content = node.tooltip
break
if tooltip_content is None:
tooltip.display = False
else:
tooltip.display = True
tooltip.absolute_offset = self.app.mouse_position
tooltip.update(tooltip_content)
def _handle_mouse_move(self, event: events.MouseMove) -> None:
hover_widget: Widget | None = None
try:
if self.app.mouse_captured:
widget = self.app.mouse_captured
region = self.find_widget(widget).region
else:
(widget, region), hover = self.get_hover_widgets_at(event.x, event.y)
if hover is not None:
hover_widget = hover[0]
except errors.NoWidget:
self.app._set_mouse_over(None, None)
if self._tooltip_timer is not None:
self._tooltip_timer.stop()
if not self.app._disable_tooltips:
try:
self.get_child_by_type(Tooltip).display = False
except NoMatches:
pass
else:
self.app._set_mouse_over(widget, hover_widget)
widget.hover_style = event.style
if widget is self:
self.post_message(event)
else:
mouse_event = self._translate_mouse_move_event(event, widget, region)
mouse_event._set_forwarded()
widget._forward_event(mouse_event)
if not self.app._disable_tooltips:
try:
tooltip = self.get_child_by_type(Tooltip)
except NoMatches:
pass
else:
if self._tooltip_widget != widget or not tooltip.display:
self._tooltip_widget = widget
if self._tooltip_timer is not None:
self._tooltip_timer.stop()
self._tooltip_timer = self.set_timer(
self.app.TOOLTIP_DELAY,
partial(self._handle_tooltip_timer, widget),
name="tooltip-timer",
)
else:
tooltip.display = False
@staticmethod
def _translate_mouse_move_event(
event: events.MouseMove, widget: Widget, region: Region
) -> events.MouseMove:
"""
Returns a mouse move event whose relative coordinates are translated to
the origin of the specified region.
"""
return events.MouseMove(
widget,
event._x - region.x,
event._y - region.y,
event._delta_x,
event._delta_y,
event.button,
event.shift,
event.meta,
event.ctrl,
screen_x=event._screen_x,
screen_y=event._screen_y,
style=event.style,
)
def _forward_event(self, event: events.Event) -> None:
if event.is_forwarded:
return
event._set_forwarded()
if isinstance(event, (events.Enter, events.Leave)):
self.post_message(event)
elif isinstance(event, events.MouseMove):
event.style = self.get_style_at(event.screen_x, event.screen_y)
self._handle_mouse_move(event)
if self._selecting:
self._box_select = event.shift
select_widget, select_offset = self.get_widget_and_offset_at(
event.x, event.y
)
if (
self._select_end is not None
and select_offset is None
and event.y > self._select_end[1].y
):
end_widget = self._select_end[0]
select_offset = end_widget.content_region.bottom_right_inclusive
self._select_end = (
end_widget,
event.screen_offset,
select_offset,
)
elif (
select_widget is not None
and select_widget.allow_select
and select_offset is not None
):
self._select_end = (
select_widget,
event.screen_offset,
select_offset,
)
elif isinstance(event, events.MouseEvent):
if isinstance(event, events.MouseUp):
if (
self._mouse_down_offset is not None
and self._mouse_down_offset == event.screen_offset
):
self.clear_selection()
self._mouse_down_offset = None
self._selecting = False
self.post_message(events.TextSelected())
elif isinstance(event, events.MouseDown) and not self.app.mouse_captured:
self._box_select = event.shift
self._mouse_down_offset = event.screen_offset
select_widget, select_offset = self.get_widget_and_offset_at(
event.screen_x, event.screen_y
)
if (
select_widget is not None
and select_widget.allow_select
and self.screen.allow_select
and self.app.ALLOW_SELECT
):
self._selecting = True
if select_widget is not None and select_offset is not None:
self.text_selection_started_signal.publish(self)
self._select_start = (
select_widget,
event.screen_offset,
select_offset,
)
else:
self._selecting = False
try:
if self.app.mouse_captured:
widget = self.app.mouse_captured
region = self.find_widget(widget).region
else:
widget, region = self.get_widget_at(event.x, event.y)
except errors.NoWidget:
self.set_focus(None)
else:
if isinstance(event, events.MouseDown):
focusable_widget = self.get_focusable_widget_at(event.x, event.y)
if (
focusable_widget is not None
and focusable_widget.focus_on_click()
):
self.set_focus(focusable_widget, scroll_visible=False)
event.style = self.get_style_at(event.screen_x, event.screen_y)
if widget.loading:
return
if widget is self:
event._set_forwarded()
self.post_message(event)
else:
widget._forward_event(event._apply_offset(-region.x, -region.y))
else:
self.post_message(event)
def _key_escape(self) -> None:
self.clear_selection()
def _watch__select_end(
self, select_end: tuple[Widget, Offset, Offset] | None
) -> None:
"""When select_end changes, we need to compute which widgets and regions are selected.
Args:
select_end: The end selection.
"""
if select_end is None or self._select_start is None:
# Nothing to select
return
select_start = self._select_start
start_widget, screen_start, start_offset = select_start
end_widget, screen_end, end_offset = select_end
if start_widget is end_widget:
# Simplest case, selection starts and ends on the same widget
self.selections = {
start_widget: Selection.from_offsets(start_offset, end_offset)
}
return
select_start, select_end = sorted(
[select_start, select_end],
key=lambda selection: (selection[0].region.offset.transpose),
)
start_widget, _screen_start, start_offset = select_start
end_widget, _screen_end, end_offset = select_end
select_regions: list[Region] = []
start_region = start_widget.content_region
end_region = end_widget.content_region
if end_region.y <= start_region.bottom or self._box_select:
select_regions.append(Region.union(start_region, end_region))
else:
try:
container_region = Region.from_union(
[
start_widget.select_container.content_region,
end_widget.select_container.content_region,
]
)
except NoMatches:
return
start_region = Region.from_corners(
start_region.x,
start_region.y,
container_region.right,
start_region.bottom,
)
end_region = Region.from_corners(
container_region.x,
end_region.y,
end_region.right,
end_region.bottom,
)
select_regions.append(start_region)
select_regions.append(end_region)
mid_height = end_region.y - start_region.bottom
if mid_height > 0:
mid_region = Region.from_corners(
container_region.x,
start_region.bottom,
container_region.right,
start_region.bottom + mid_height,
)
select_regions.append(mid_region)
spatial_map: SpatialMap[Widget] = SpatialMap()
spatial_map.insert(
[
(widget.region, NULL_OFFSET, False, False, widget)
for widget in self._compositor.visible_widgets.keys()
]
)
highlighted_widgets: set[Widget] = set()
for region in select_regions:
covered_widgets = spatial_map.get_values_in_region(region)
covered_widgets = [
widget
for widget in covered_widgets
if region.overlaps(widget.content_region)
]
highlighted_widgets.update(covered_widgets)
highlighted_widgets -= {self, start_widget, end_widget}
select_all = SELECT_ALL
self.selections = {
start_widget: Selection(start_offset, None),
**{
widget: select_all
for widget in sorted(
highlighted_widgets,
key=lambda widget: widget.content_region.offset.transpose,
)
},
end_widget: Selection(None, end_offset),
}
def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete:
"""Dismiss the screen, optionally with a result.
Any callback provided in [push_screen][textual.app.App.push_screen] will be invoked with the supplied result.
Only the active screen may be dismissed. This method will produce a warning in the logs if
called on an inactive screen (but otherwise have no effect).
!!! warning
Textual will raise a [`ScreenError`][textual.app.ScreenError] if you await the return value from a
message handler on the Screen being dismissed. If you want to dismiss the current screen, you can
call `self.dismiss()` _without_ awaiting.
Args:
result: The optional result to be passed to the result callback.
"""
_rich_traceback_omit = True
if not self.is_active:
self.log.warning("Can't dismiss inactive screen")
return AwaitComplete()
if self._result_callbacks:
callback = self._result_callbacks[-1]
callback(result)
await_pop = self.app.pop_screen()
def pre_await() -> None:
"""Called by the AwaitComplete object."""
_rich_traceback_omit = True
if active_message_pump.get() is self:
from textual.app import ScreenError
raise ScreenError(
"Can't await screen.dismiss() from the screen's message handler; try removing the await keyword."
)
await_pop.set_pre_await_callback(pre_await)
return await_pop
def pop_until_active(self) -> None:
"""Pop any screens on top of this one, until this screen is active.
Raises:
ScreenError: If this screen is not in the current mode.
"""
from textual.app import ScreenError
try:
self.app._pop_to_screen(self)
except ScreenError:
# More specific error message
raise ScreenError(
f"Can't make {self} active as it is not in the current stack."
) from None
async def action_dismiss(self, result: ScreenResultType | None = None) -> None:
"""A wrapper around [`dismiss`][textual.screen.Screen.dismiss] that can be called as an action.
Args:
result: The optional result to be passed to the result callback.
"""
await self._flush_next_callbacks()
self.dismiss(result)
def can_view_entire(self, widget: Widget) -> bool:
"""Check if a given widget is fully within the current screen.
Note: This doesn't necessarily equate to a widget being visible.
There are other reasons why a widget may not be visible.
Args:
widget: A widget.
Returns:
`True` if the entire widget is in view, `False` if it is partially visible or not in view.
"""
if widget not in self._compositor.visible_widgets:
return False
# If the widget is one that overlays the screen...
if widget.styles.overlay == "screen":
# ...simply check if it's within the screen's region.
return widget.region in self.region
# Failing that fall back to normal checking.
return super().can_view_entire(widget)
def can_view_partial(self, widget: Widget) -> bool:
"""Check if a given widget is at least partially within the current view.
Args:
widget: A widget.
Returns:
`True` if the any part of the widget is in view, `False` if it is completely outside of the screen.
"""
if widget not in self._compositor.visible_widgets:
return False
# If the widget is one that overlays the screen...
if widget.styles.overlay == "screen":
# ...simply check if it's within the screen's region.
return widget.region in self.region
# Failing that fall back to normal checking.
return super().can_view_partial(widget)
def validate_title(self, title: Any) -> str | None:
"""Ensure the title is a string or `None`."""
return None if title is None else str(title)
def validate_sub_title(self, sub_title: Any) -> str | None:
"""Ensure the sub-title is a string or `None`."""
return None if sub_title is None else str(sub_title)
@rich.repr.auto
class ModalScreen(Screen[ScreenResultType]):
"""A screen with bindings that take precedence over the App's key bindings.
The default styling of a modal screen will dim the screen underneath.
"""
DEFAULT_CSS = """
ModalScreen {
layout: vertical;
overflow-y: auto;
background: $background 60%;
&:ansi {
background: transparent;
}
}
"""
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self._modal = True
class SystemModalScreen(ModalScreen[ScreenResultType], inherit_css=False):
"""A variant of `ModalScreen` for internal use.
This version of `ModalScreen` allows us to build system-level screens;
the type being used to indicate that the screen should be isolated from
the main application.
Note:
This screen is set to *not* inherit CSS.
"""