diff --git a/src/textual/_activation.py b/src/textual/_activation.py index 5a6feab2a8..7ec5b635d4 100644 --- a/src/textual/_activation.py +++ b/src/textual/_activation.py @@ -8,6 +8,8 @@ from textual import events from textual._callback import invoke +from functools import partial + from textual._event_broker import HandlerArguments, NoHandler, extract_handler_actions from textual.geometry import Region from textual.errors import NoWidget @@ -40,8 +42,8 @@ def from_paint(cls, widget: Widget, region: Region, strips: list[Strip], button= for owner in widget.ancestors_with_self for target in owner.activation_targets(exposed, meta, button)), 1) else: - if handler.action is not None: - yield StyleTarget(widget, exposed, handler, button=button) + yield from handler.action.activations( + partial(StyleTarget, widget, exposed, handler, button=button)) x += width def available(self) -> bool: diff --git a/src/textual/_event_broker.py b/src/textual/_event_broker.py index 832d550588..267ee2471e 100644 --- a/src/textual/_event_broker.py +++ b/src/textual/_event_broker.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Any, NamedTuple -from textual import actions, log +from textual import actions class NoHandler(Exception): @@ -12,13 +12,13 @@ class HandlerArguments(NamedTuple): """Information for event handler.""" modifiers: set[str] - action: actions.StyleAction | None + action: actions.StyleAction @classmethod def decode(cls, modifiers: set[str], value: Any) -> HandlerArguments: """The one boundary where a style's handler meta becomes an action.""" if isinstance(value, actions.StyleAction): - action: actions.StyleAction | None = value + action: actions.StyleAction = value elif isinstance(value, str): action = actions.ParsedAction(actions.parse(value)) elif isinstance(value, tuple) and len(value) == 2: @@ -26,17 +26,12 @@ def decode(cls, modifiers: set[str], value: Any) -> HandlerArguments: namespace, parsed_name, _ = actions.parse(name) action = actions.ParsedAction((namespace, parsed_name, parameters)) else: - action = None + action = actions.NoStyleAction() 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 self.action.run(app, default_namespace) - return True + return await self.action.run(app, default_namespace) def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArguments: diff --git a/src/textual/actions.py b/src/textual/actions.py index e6e9752e1b..ea21e0ba26 100644 --- a/src/textual/actions.py +++ b/src/textual/actions.py @@ -9,11 +9,16 @@ from typing_extensions import TypeAlias +from textual import log + ActionParseResult: TypeAlias = "tuple[str, str, tuple[object, ...]]" """An action is its name and the arbitrary tuple of its arguments.""" if TYPE_CHECKING: + from collections.abc import Callable + + from textual._activation import Activation from textual.app import App from textual.dom import DOMNode @@ -26,7 +31,29 @@ class StyleAction(ABC): """ @abstractmethod - async def run(self, app: App, namespace: DOMNode) -> None: ... + async def run(self, app: App, namespace: DOMNode) -> bool: + """Run the action; whether there was one to run.""" + + async def secondary(self, app: App, namespace: DOMNode, event: object) -> bool: + """A secondary (right) click on the drawn text; most actions have no secondary use.""" + return False + + def activations(self, target: Callable[[], Activation]) -> tuple[Activation, ...]: + """The click target a cell carrying this action offers.""" + return (target(),) + + +@dataclass(frozen=True) +class NoStyleAction(StyleAction): + """Meta that names no action: the cell offers no click and a click on it does nothing.""" + + async def run(self, app: App, namespace: DOMNode) -> bool: + if app.debug: + log.warning("Can't parse action from style meta; check your console markup syntax") + return False + + def activations(self, target: Callable[[], Activation]) -> tuple[Activation, ...]: + return () @dataclass(frozen=True) @@ -35,8 +62,9 @@ class ParsedAction(StyleAction): parsed: ActionParseResult - async def run(self, app: App, namespace: DOMNode) -> None: + async def run(self, app: App, namespace: DOMNode) -> bool: await app.run_action(self.parsed, namespace) + return True class SkipAction(Exception): diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index dbdb48ac5a..92939a2e80 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -221,6 +221,10 @@ class OpensMarkdownLinks(DOMNode): def open_link(self, href: str) -> None: self.post_message(Markdown.LinkClicked(self.query_ancestor(Markdown), href)) + async def link_menu(self, href: str, event: object) -> bool: + """A secondary click on a link; plain Markdown offers no menu.""" + return False + @dataclass(frozen=True) class MarkdownLink(StyleAction): @@ -228,8 +232,12 @@ class MarkdownLink(StyleAction): href: str - async def run(self, app: App, namespace: DOMNode) -> None: + async def run(self, app: App, namespace: DOMNode) -> bool: cast(OpensMarkdownLinks, namespace).open_link(self.href) + return True + + async def secondary(self, app: App, namespace: DOMNode, event: object) -> bool: + return await cast(OpensMarkdownLinks, namespace).link_menu(self.href, event) class MarkdownBlock(OpensMarkdownLinks, Static):