diff --git a/src/textual/_activation.py b/src/textual/_activation.py index 855033d657..5a6feab2a8 100644 --- a/src/textual/_activation.py +++ b/src/textual/_activation.py @@ -2,10 +2,11 @@ from __future__ import annotations from abc import abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from itertools import islice from typing import TYPE_CHECKING, Callable +from textual import events from textual._callback import invoke from textual._event_broker import HandlerArguments, NoHandler, extract_handler_actions from textual.geometry import Region @@ -22,24 +23,25 @@ class Activation: region: Region @classmethod - def from_paint(cls, widget: Widget, region: Region, strips: list[Strip]): - """Decode exposed click metadata and primary declarations once.""" + def from_paint(cls, widget: Widget, region: Region, strips: list[Strip], button=1): + """Decode the selected click intent from exposed published cells.""" for y, strip in zip(region.line_range, strips): x = region.x for segment in strip: width = segment.cell_length if width: exposed = Region(x, y, width, 1) + meta = segment.style.meta if segment.style else {} try: - handler = extract_handler_actions("click", segment.style.meta if segment.style else {}) + handler = extract_handler_actions("click", meta) except NoHandler: # The nearest declaring ancestor owns this exposed cell. yield from islice((target for owner in widget.ancestors_with_self - for target in owner.primary_activation_targets(exposed)), 1) + for target in owner.activation_targets(exposed, meta, button)), 1) else: if handler.action is not None: - yield StyleTarget(widget, exposed, handler) + yield StyleTarget(widget, exposed, handler, button=button) x += width def available(self) -> bool: @@ -69,8 +71,9 @@ async def activate(self) -> None: ... @dataclass(frozen=True) -class PrimaryTarget(Activation): +class CallbackTarget(Activation): callback: Callable + arguments: tuple[object, ...] = field(default=(), kw_only=True) @property def identity(self): @@ -78,12 +81,13 @@ def identity(self): async def activate(self) -> None: if self.available(): - await invoke(self.callback) + await invoke(self.callback, *self.arguments) @dataclass(frozen=True) class StyleTarget(Activation): handler: HandlerArguments + button: int = field(default=1, kw_only=True) @property def identity(self): @@ -101,20 +105,48 @@ def available(self) -> bool: async def activate(self) -> None: if self.available(): - await self.widget.app.dispatch_style_action(self.handler, None, self.widget) + widget = self.widget + screen = widget.screen + published_region, _ = screen._compositor.published_widgets[widget] + event = events.Click( + widget, + x=self.region.x - published_region.x, + y=self.region.y - published_region.y, + delta_x=0, + delta_y=0, + button=self.button, + shift=False, + meta=False, + ctrl=False, + screen_x=self.region.x, + screen_y=self.region.y, + style=screen.get_style_at(self.region.x, self.region.y), + ) + await widget.app.dispatch_style_action(self.handler, event, widget) class PrimaryActivation: """A widget declares its existing primary effect; discovery is shared.""" def primary_activation_targets(self, region): - return (PrimaryTarget(self, region, self.primary_activation),) + return (CallbackTarget(self, region, self.primary_activation),) @property @abstractmethod def primary_activation(self) -> Callable: ... +class ContextActivation: + """A widget declares its contextual effect at the published click offset.""" + + def context_activation_targets(self, region): + return (CallbackTarget(self, region, self.context_activation, + arguments=(region.offset,)),) + + @abstractmethod + def context_activation(self, offset) -> None: ... + + class FocusActivation(PrimaryActivation): """Text entry is reached by focus, never by submitting or editing its value.""" diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5d4ea7b4a9..4cb4e5f65d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -2209,8 +2209,8 @@ def render_strips(self, size: Size | None = None) -> list[Strip]: ) for y, chop in enumerate(chops[: size.height])] return render_strips - def activation_targets(self): - """Borrow only exposed published rows through the existing paint walk.""" + def activation_targets(self, button=1): + """Borrow the selected click effect from exposed published rows.""" targets = [] seen = [] widgets = self.published_widgets @@ -2219,7 +2219,7 @@ def activation_targets(self): return targets def collect(widget, region, strips): - for target in Activation.from_paint(widget, region, strips): + for target in Activation.from_paint(widget, region, strips, button): if target.identity not in seen and target.available(): seen.append(target.identity) targets.append(target) diff --git a/src/textual/dom.py b/src/textual/dom.py index 6abb12a063..06e4cc5d00 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1411,10 +1411,22 @@ def geometry(self) -> BoxGeometry: """The original sizing/placement source; declared widgets override this.""" return self.styles + def activation_targets(self, region, meta, button=1): + """Resolve a published cell through this node's click capabilities.""" + if button == 1: + return self.primary_activation_targets(region) + if button == 3: + return self.context_activation_targets(region) + return () + def primary_activation_targets(self, region): """Targets explicitly owned here; ordinary nodes contribute no effect.""" return () + def context_activation_targets(self, region): + """Ordinary nodes contribute no contextual effect.""" + return () + def check_consume_key(self, key: str, character: str | None) -> bool: """Check if the widget may consume the given key. diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py index 3e8e64c61d..392b74e4bd 100644 --- a/src/textual/widgets/_hints.py +++ b/src/textual/widgets/_hints.py @@ -1,6 +1,7 @@ """One transient native hint interaction over published activation targets.""" from __future__ import annotations +from enum import IntEnum from typing import ClassVar from textual import events @@ -11,20 +12,44 @@ from textual.widget import Widget +class HintClick(IntEnum): + """The two pointer intents offered by native hint sessions.""" + + LEFT = 1 + RIGHT = 3 + + @property + def description(self) -> str: + return f"{self.name.title()}-click hints" + + @property + def caption(self) -> str: + return f"{self.description} ยท Escape cancels" + + class HintHost: """Screen capability; mounted children are the only session inventory.""" async def action_show_hints(self) -> None: + await self._show_hints(HintClick.LEFT) + + async def action_show_context_hints(self) -> None: + await self._show_hints(HintClick.RIGHT) + + async def _show_hints(self, click: HintClick) -> None: self.dismiss_hints() - targets = self._compositor.activation_targets() + targets = self._compositor.activation_targets(click) if not targets: return - session = HintSession(self, targets) + session = HintSession(self, targets, click) await self.mount(*session.labels, session) session.focus_on_host() MODE_BINDINGS: ClassVar[tuple[ModeBinding, ...]] = ( - ModeBinding("f,ctrl+f", action_show_hints, "Hints", mode=VimMode, show=False), + ModeBinding("f", action_show_hints, HintClick.LEFT.description, + mode=VimMode, show=False), + ModeBinding("ctrl+f", action_show_context_hints, HintClick.RIGHT.description, + mode=VimMode, show=False), ) def dismiss_hints(self, *, restore=True) -> None: @@ -71,12 +96,18 @@ def render(self) -> Content: class HintSession(Widget, can_focus=True): """Own label input and its short-lived paint; never copy navigation state.""" + DEFAULT_CSS = """ + HintSession { + text-style: bold reverse; + } + """ alphabet = "asdfghjklqwertyuiopzxcvbnm" ALLOW_SELECT = False hit_test_transparent = True - def __init__(self, screen, targets): + def __init__(self, screen, targets, click: HintClick): super().__init__() + self.click = click self.original_focus = screen.focused targets = sorted(targets, key=lambda target: (target.region.y, target.region.x)) self.prefix = "" @@ -84,7 +115,9 @@ def __init__(self, screen, targets): while len(self.alphabet) ** width < len(targets): width += 1 size = screen.size - placed: dict[int, list[tuple[int, int]]] = {} + placed: dict[int, list[tuple[int, int]]] = { + size.height - 1: [(0, min(size.width, len(click.caption)))] + } labels = [] for index, target in enumerate(targets): text = self.label(index, width) @@ -93,8 +126,13 @@ def __init__(self, screen, targets): self.labels = tuple(labels) self.styles.position = "absolute" self.styles.overlay = "screen" - self.styles.width = 0 - self.styles.height = 0 + self.styles.layer = "_tooltips" + self.styles.width = min(size.width, len(click.caption)) + self.styles.height = 1 + self.absolute_offset = Offset(0, size.height - 1) + + def render(self) -> Content: + return Content(self.click.caption) @staticmethod def free_offset(placed, size, origin, width): @@ -166,7 +204,7 @@ def restore_focus(self): if self.screen is not self.app.screen: return original = self.original_focus - if original is None or original.is_attached: + if original is None or (original.is_attached and original.screen is self.screen): self.screen.set_focus(original, scroll_visible=False) def dismiss(self, *, target=None, restore=True): diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 44f0074cdb..8c763a508a 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -7,6 +7,7 @@ from rich.segment import Segment from textual import _widget_navigation, events +from textual._activation import Activation from textual._loop import loop_last from textual.binding import Binding, BindingType from textual.cache import LRUCache @@ -91,6 +92,28 @@ def __rich_repr__(self) -> rich.repr.Result: yield "_divider", self._divider, False +@dataclass(frozen=True) +class OptionActivation(Activation): + """Select the same option, never whatever later occupies its old index.""" + + widget: OptionList + option: Option + + @property + def identity(self): + return self.widget, self.option + + def available(self) -> bool: + if not self.widget.option_enabled(self.option) or not super().available(): + return False + style = self.widget.screen.get_style_at(self.region.x, self.region.y) + return self.widget.option_from_meta(style.meta) is self.option + + async def activate(self) -> None: + if self.available(): + self.widget.select_option(self.option) + + @dataclass class _LineCache: """Cached line information.""" @@ -725,9 +748,32 @@ async def _on_click(self, event: events.Click) -> None: Args: event: The click event. """ - clicked_option: int | None = event.style.meta.get("option") - if clicked_option is not None and not self._options[clicked_option].disabled: - self.highlighted = clicked_option + option = self.option_from_meta(event.style.meta) + if option is not None: + self.select_option(option) + + def option_from_meta(self, meta) -> Option | None: + """Decode the option marker shared by pointer and published-cell hints.""" + if "option" in meta: + index = meta["option"] + if 0 <= index < self.option_count: + return self._options[index] + return None + + def option_enabled(self, option: Option) -> bool: + """Membership and enabled state belong to the list, not its callers.""" + return option in self._option_to_index and not option.disabled + + def activation_targets(self, region, meta, button=1): + option = self.option_from_meta(meta) + if option is not None and self.option_enabled(option): + return (OptionActivation(self, region, option),) + return () + + def select_option(self, option: Option) -> None: + """Pointer and hint selection share highlight and the native action.""" + if self.option_enabled(option): + self.highlighted = self._option_to_index[option] self.action_select() def _get_left_gutter_width(self) -> int: @@ -744,7 +790,10 @@ def _on_mouse_move(self, event: events.MouseMove) -> None: Args: event: The mouse movement event. """ - self._mouse_hovering_over = event.style.meta.get("option") + option = self.option_from_meta(event.style.meta) + self._mouse_hovering_over = ( + None if option is None else self._option_to_index[option] + ) def _on_leave(self, _: events.Leave) -> None: """React to the mouse leaving the widget."""