2012 lines
72 KiB
Python
2012 lines
72 KiB
Python
"""
|
|
|
|
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.
|
|
"""
|