Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions src/textual/_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -69,21 +71,23 @@ 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):
return self.widget, self.callback

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):
Expand All @@ -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."""

Expand Down
6 changes: 3 additions & 3 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 46 additions & 8 deletions src/textual/widgets/_hints.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -71,20 +96,28 @@ 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 = ""
width = 1
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)
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
57 changes: 53 additions & 4 deletions src/textual/widgets/_option_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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."""
Expand Down