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
111 changes: 111 additions & 0 deletions src/textual/_activation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Activation targets borrow the widget's original effect and displayed cells."""
from __future__ import annotations

from abc import abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable

from textual._callback import invoke
from textual._event_broker import HandlerArguments, NoHandler, extract_handler_actions
from textual.geometry import Region
from textual.errors import NoWidget

if TYPE_CHECKING:
from textual.strip import Strip
from textual.widget import Widget


@dataclass(frozen=True)
class Activation:
widget: Widget
region: Region

@classmethod
def from_paint(cls, widget: Widget, region: Region, strips: list[Strip]):
"""Decode exposed click metadata and primary declarations once."""
primary = next((owner for owner in widget.ancestors_with_self
if isinstance(owner, PrimaryActivation)), None)
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)
try:
handler = extract_handler_actions("click", segment.style.meta if segment.style else {})
except NoHandler:
if primary is not None:
yield PrimaryTarget(primary, exposed, primary.primary_activation)
else:
if handler.action is not None:
yield StyleTarget(widget, exposed, handler)
x += width

def available(self) -> bool:
widget = self.widget
if (
not widget.is_attached
or widget.is_disabled
or not widget.visible
or any(not node.display for node in widget.ancestors_with_self)
):
return False
screen = widget.screen
if screen is not widget.app.screen:
return False
try:
hit, _ = screen.get_widget_at(self.region.x, self.region.y)
except NoWidget:
return False
return hit is widget or widget in hit.ancestors

@property
@abstractmethod
def identity(self): ...

@abstractmethod
async def activate(self) -> None: ...


@dataclass(frozen=True)
class PrimaryTarget(Activation):
callback: Callable

@property
def identity(self):
return self.widget, self.callback

async def activate(self) -> None:
if self.available():
await invoke(self.callback)


@dataclass(frozen=True)
class StyleTarget(Activation):
handler: HandlerArguments

@property
def identity(self):
return self.widget, self.handler.action

def available(self) -> bool:
if not super().available():
return False
style = self.widget.screen.get_style_at(self.region.x, self.region.y)
try:
current = extract_handler_actions("click", style.meta)
except NoHandler:
return False
return current == self.handler

async def activate(self) -> None:
if self.available():
await self.widget.app.dispatch_style_action(self.handler, None, self.widget)


class PrimaryActivation:
"""A widget declares its existing primary effect; discovery is shared."""

@property
@abstractmethod
def primary_activation(self) -> Callable: ...
33 changes: 33 additions & 0 deletions src/textual/_clipboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""The local clipboard payload, including editing semantics lost by plain text."""
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class ClipboardContent:
text: str

@classmethod
def of(cls, value: str | ClipboardContent) -> ClipboardContent:
return value if isinstance(value, ClipboardContent) else cls(value)

def insertion(self, editor, before, count):
return editor.navigator.get_location_column_offset(editor.cursor_location, 0 if before else 1), self.text * count

def paste(self, editor, before, count):
location, text = self.insertion(editor, before, count)
mode = editor.editing_mode
mode.suspend(editor)
editor.replace(text, location, location, maintain_selection_offset=False)
editor.move_cursor(location)
mode.resume(editor)


class LinewiseClipboard(ClipboardContent):
def insertion(self, editor, before, count):
row = editor.cursor_location[0] + (0 if before else 1)
text = self.text * count
if row < editor.document.line_count:
return (row, 0), text
return editor.document.end, editor.document.newline + text.removesuffix(editor.document.newline)
34 changes: 30 additions & 4 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from rich.style import Style

from textual import errors, events
from textual._activation import Activation
from textual._loop import loop_last
from textual._spatial_map import SpatialMap
from textual.geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing
Expand Down Expand Up @@ -1687,7 +1688,8 @@ def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
contains = Region.contains
if self.size.height > y >= 0:
for widget, (region, clip) in self.published_widgets.items():
if contains(region, x, y) and contains(clip, x, y) and widget.visible:
if (contains(region, x, y) and contains(clip, x, y) and widget.visible
and not widget.hit_test_transparent):
yield widget, region

def get_style_at(self, x: int, y: int) -> Style:
Expand Down Expand Up @@ -1895,7 +1897,7 @@ def _get_renders(
self, crop: Region | None = None,
render_regions: Callable[[Region], Iterable[Region]] | None = None,
*, widgets: Mapping[Widget, tuple[Region, Region]],
) -> Iterable[tuple[Region, list[Strip]]]:
) -> Iterable[tuple[Widget, Region, list[Strip]]]:
"""Get rendered widgets (lists of segments) in the composition.

Args:
Expand Down Expand Up @@ -1931,7 +1933,7 @@ def _get_renders(
for render_region in regions:
new_x, new_y, new_width, new_height = render_region
yield (
render_region,
widget, render_region,
widget.render_lines(
_Region(
new_x - region.x,
Expand Down Expand Up @@ -2181,6 +2183,27 @@ 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."""
targets = []
seen = []
widgets = self.published_widgets
bounds = self.size.region
if self.root is None or not widgets:
return targets

def collect(widget, region, strips):
for target in Activation.from_paint(widget, region, strips):
if target.identity not in seen and target.available():
seen.append(target.identity)
targets.append(target)

cuts = self._cuts_for_regions(bounds, widgets)
with self._using_geometry(self.root, self._published_map):
self._render_chops(bounds, self._regions_to_spans((bounds,)), widgets=widgets,
cuts=cuts, bounds=bounds, on_render=collect)
return targets

def published_geometry(
self, roots: Iterable[Widget],
) -> Iterator[tuple[Widget, MapGeometry]]:
Expand All @@ -2203,6 +2226,7 @@ def _render_chops(
spans: Iterable[tuple[int, int, int]],
*, widgets: Mapping[Widget, tuple[Region, Region]], cuts: list[list[int]],
bounds: Region,
on_render: Callable[[Widget, Region, list[Strip]], None] | None = None,
) -> Sequence[Mapping[int, Strip]]:
"""Render update 'chops'.

Expand Down Expand Up @@ -2261,7 +2285,9 @@ def render_regions(region: Region) -> Iterable[Region]:
# Go through all the renders in reverse order and fill buckets with no render
renders = self._get_renders(crop, render_regions, widgets=widgets)

for render_region, strips in renders:
for widget, render_region, strips in renders:
if on_render is not None:
on_render(widget, render_region, strips)
render_x = render_region.x
first_cut, last_cut = render_region.column_span

Expand Down
26 changes: 24 additions & 2 deletions src/textual/_event_broker.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from typing import Any, NamedTuple
from textual import actions, log


class NoHandler(Exception):
Expand All @@ -11,7 +12,28 @@ class HandlerArguments(NamedTuple):
"""Information for event handler."""

modifiers: set[str]
action: Any
action: actions.ActionParseResult | None

@classmethod
def decode(cls, modifiers: set[str], value: Any) -> HandlerArguments:
if isinstance(value, str):
action = actions.parse(value)
elif isinstance(value, tuple) and len(value) == 2:
name, parameters = value
namespace, parsed_name, _ = actions.parse(name)
action = namespace, parsed_name, parameters
else:
action = None
return cls(modifiers, action)

async def run(self, app, default_namespace) -> bool:
"""Consumers use the action decoded once at the style boundary."""
if self.action is None:
if app.debug:
log.warning("Can't parse action from style meta; check your console markup syntax")
return False
await app.run_action(self.action, default_namespace)
return True


def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArguments:
Expand All @@ -33,5 +55,5 @@ def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArg
name_args = key[1:].split(".")
if name_args[: len(event_path)] == event_path:
modifiers = name_args[len(event_path) :]
return HandlerArguments(set(modifiers), value)
return HandlerArguments.decode(set(modifiers), value)
raise NoHandler(f"No handler for {event_name!r}")
3 changes: 2 additions & 1 deletion src/textual/_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,8 @@ def _sequence_to_key_events(
# If the sequence mapped to a tuple, then it's values from the
# `Keys` enum. Raise key events from what we find in the tuple.
for key in keys:
yield events.Key(key.value, sequence if len(sequence) == 1 else None)
name = f"alt+{key.value}" if alt else key.value
yield events.Key(name, sequence if len(sequence) == 1 else None)
return
# If keys is a string, the intention is that it's a mapping to a
# character, which should really be treated as the sequence for the
Expand Down
44 changes: 20 additions & 24 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
from textual._context import message_hook as message_hook_context_var
from textual._dispatch_key import dispatch_key
from textual._event_broker import NoHandler, extract_handler_actions
from textual._clipboard import ClipboardContent
from textual._files import generate_datetime_filename
from textual._path import (
CSSPathType,
Expand Down Expand Up @@ -873,7 +874,7 @@ def __init__(
self._css_update_count: int = 0
"""Incremented when CSS is invalidated."""

self._clipboard: str = ""
self.clipboard_content = ClipboardContent("")
"""Contents of local clipboard."""

self.supports_smooth_scrolling: bool = False
Expand Down Expand Up @@ -1050,7 +1051,7 @@ def clipboard(self) -> str:
Note, that this only contains text copied in the app, and not
text copied from elsewhere in the OS.
"""
return self._clipboard
return self.clipboard_content.text

def _realtime_animation_begin(self) -> None:
"""A scroll or other animation that must be smooth has begun."""
Expand Down Expand Up @@ -1818,7 +1819,7 @@ def get_loading_widget(self) -> Widget:

return LoadingIndicator()

def copy_to_clipboard(self, text: str) -> None:
def copy_to_clipboard(self, text: str | ClipboardContent) -> None:
"""Copy text to the clipboard.

!!! note
Expand All @@ -1828,7 +1829,11 @@ def copy_to_clipboard(self, text: str) -> None:
Args:
text: Text you wish to copy to the clipboard.
"""
self._clipboard = text
self.clipboard_content = ClipboardContent.of(text)
self.publish_clipboard(self.clipboard_content.text)

def publish_clipboard(self, text: str) -> None:
"""Transport hook; retaining the typed local payload is App's job."""
if self._driver is None:
return
import base64
Expand Down Expand Up @@ -4280,6 +4285,11 @@ def escape_to_minimize(self) -> bool:
If the classvar on the screen is *not* set (and left as `None`), then the app's `ESCAPE_TO_MINIMIZE` is used.

"""
focused = self.screen.focused
if focused is not None and any(
node.check_consume_key("escape", None) for node in focused.ancestors_with_self
):
return False
return bool(
self.ESCAPE_TO_MINIMIZE
if self.screen.ESCAPE_TO_MINIMIZE is None
Expand Down Expand Up @@ -4428,31 +4438,17 @@ async def _broker_event(
except AttributeError:
return False
try:
_modifiers, action = extract_handler_actions(event_name, style.meta)
handler = extract_handler_actions(event_name, style.meta)
except NoHandler:
return False
else:
event.stop()

if isinstance(action, str):
await self.run_action(action, default_namespace)
elif isinstance(action, tuple) and len(action) == 2:
action_name, action_params = action
namespace, parsed_action, _ = actions.parse(action_name)
await self.run_action(
(namespace, parsed_action, action_params),
default_namespace,
)
else:
if isinstance(action, tuple) and self.debug:
# It's a tuple and made it this far, which means it'll be a
# malformed action. This is a no-op, but let's log that
# anyway.
log.warning(
f"Can't parse @{event_name} action from style meta; check your console markup syntax"
)
return False
return True
return await self.dispatch_style_action(handler, event, default_namespace)

async def dispatch_style_action(self, handler, event, default_namespace) -> bool:
"""Shared decoded-action hook for pointer and keyboard activation."""
return await handler.run(self, default_namespace)

async def _on_update(self, message: messages.Update) -> None:
message.stop()
Expand Down
Loading