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
6 changes: 4 additions & 2 deletions src/textual/_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 5 additions & 10 deletions src/textual/_event_broker.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -12,31 +12,26 @@ 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:
name, parameters = value
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:
Expand Down
32 changes: 30 additions & 2 deletions src/textual/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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):
Expand Down
10 changes: 9 additions & 1 deletion src/textual/widgets/_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,15 +221,23 @@ 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):
"""A Markdown link or image: its click opens the href on the widget that drew it."""

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):
Expand Down
Loading