From 42917f0248cc00cb5b3eafa8cd32dfc8fbb4760e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 00:40:11 -0500 Subject: [PATCH 1/3] The keymap guard is zero everywhere; links click as themselves Every KeymapMeasure asserts zero and `ceiling` is gone: a tolerated count of keys written outside their owner was a ratchet on slop. ActionMethods exempts exactly the operations Textual declares (a navigation contract's methods, a MethodBinding's), read from the classes. With OpenHCSDev/textual#244, the eleven `def action_*` sites are operations, not names: HistoryWindow overrides ScrollsView, TargetTree implements MovesHorizontalCursor, the prompt overrides TextArea's declared editing methods. The two link actions are deleted: - a drawn link's style meta carries OpenLineLink(link), a StyleAction in the new toad/link_click.py; LineLink.click and its "open" action name leave line_blocks, which declares that nothing in it imports a frontend; - OpensLinks inherits Textual's OpensMarkdownLinks, and open_link decodes a toad-link href once; the right-click menu reads OpenLineLink.link_of, and the app's inline import is gone. Co-Authored-By: Claude Opus 5.5 --- src/toad/app.py | 4 +-- src/toad/line_blocks.py | 19 +--------- src/toad/link_click.py | 36 +++++++++++++++++++ src/toad/widgets/conversation.py | 2 +- src/toad/widgets/inline_message.py | 3 +- src/toad/widgets/linked_rows.py | 16 ++++----- src/toad/widgets/transcript_lines.py | 3 +- tests/guards/test_keymap_owner.py | 53 ++++++++++++++++------------ 8 files changed, 82 insertions(+), 54 deletions(-) create mode 100644 src/toad/link_click.py diff --git a/src/toad/app.py b/src/toad/app.py index 711d19f78..84d8338ab 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -3,6 +3,7 @@ from toad.workspace_sessions import WorkspaceSessionShutdown from inspect import isabstract from toad.comms_root import CoreConnection +from toad.link_click import OpenLineLink from toad.thread_actions import ThreadActions from toad.widgets.comms_transfer import Transfers @@ -494,8 +495,7 @@ async def on_text_selected(self) -> None: async def dispatch_style_action(self, handler, event, default_namespace) -> bool: """Right-click adds its menu to the original decoded link action.""" if isinstance(event, events.Click) and event.button == 3: - from toad.line_blocks import LineLink - if await LineLink.from_action(handler.action).copy_menu(default_namespace, event): + if await OpenLineLink.link_of(handler.action).copy_menu(default_namespace, event): return True return await super().dispatch_style_action(handler, event, default_namespace) diff --git a/src/toad/line_blocks.py b/src/toad/line_blocks.py index 45f3d1e7a..6a3f18cef 100644 --- a/src/toad/line_blocks.py +++ b/src/toad/line_blocks.py @@ -189,15 +189,9 @@ class LineLink(DeclaredFamily, affix="Link"): # Whether the text is underlined to show that it leads somewhere. underlined: ClassVar[bool] = True - # The widget action a click runs (OpensLinks.action_open), and the transport prefix of the - # Markdown href that carries a link through a string-only boundary. - open_action: ClassVar[str] = "open" + # The transport prefix of the Markdown href that carries a link through a string-only boundary. href_scheme: ClassVar[str] = "toad-link:" - @property - def click(self) -> str | tuple[str, tuple]: - return self.open_action, (self,) - async def copy_menu(self, widget, event): return False @@ -225,17 +219,6 @@ def from_href(cls, href: str) -> LineLink: raise ValueError("Expected a resolved Markdown action") return FieldCodec.decode(cls, json.loads(unquote(href.removeprefix(cls.href_scheme)))) - @classmethod - def from_action(cls, action: tuple[str, str, tuple[object, ...]]) -> LineLink: - """Decode Textual's parsed click action back into the link that encoded it (`click` - or `encoded_href`); any other action leads nowhere.""" - match action: - case ("", cls.open_action, (LineLink() as link,)): - return link - case ("", "link", (str() as href,)) if href.startswith(cls.href_scheme): - return cls.from_href(href) - return NoLink() - @dataclass(frozen=True) class NoLink(LineLink): diff --git a/src/toad/link_click.py b/src/toad/link_click.py new file mode 100644 index 000000000..ad02524b8 --- /dev/null +++ b/src/toad/link_click.py @@ -0,0 +1,36 @@ +"""The Textual frontend's click on a drawn link: the link itself rides in the style's meta.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +from metaclass_registry.mro_dispatch import MroDispatch +from textual.actions import StyleAction +from textual.widgets._markdown import MarkdownLink + +from toad.line_blocks import LineLink, NoLink + +if TYPE_CHECKING: + from textual.app import App + from textual.dom import DOMNode + + +@dataclass(frozen=True) +class OpenLineLink(StyleAction): + """A click on text a link drew: the widget that drew it opens the link (OpensLinks).""" + + link: LineLink + + async def run(self, app: App, namespace: DOMNode) -> None: + cast(MroDispatch, namespace).dispatch_sync(self.link) + + @staticmethod + def link_of(action: StyleAction | None) -> LineLink: + """The link a decoded style action opens, for a right-click's menu; a Markdown href + carries a link through markdown-it's string-only attributes. Anything else leads nowhere.""" + match action: + case OpenLineLink(link=link): + return link + case MarkdownLink(href=href) if href.startswith(LineLink.href_scheme): + return LineLink.from_href(href) + return NoLink() diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 398a132e8..a5eb227b8 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -511,7 +511,7 @@ def accepts_menu_owner(self, owner: Widget) -> bool: return self.is_current_session and self.navigation.accepts_menu_owner(owner) def open_line_link(self, link: LineLink) -> None: - self.transcript.binding.history.action_open(link) + self.transcript.binding.history.dispatch_sync(link) @property def operational_sources(self): diff --git a/src/toad/widgets/inline_message.py b/src/toad/widgets/inline_message.py index 533ad1fe2..6f50c63a9 100644 --- a/src/toad/widgets/inline_message.py +++ b/src/toad/widgets/inline_message.py @@ -9,6 +9,7 @@ from toad.core.projection import MroProjection from toad.inline_spans import inline_spans +from toad.link_click import OpenLineLink from toad.line_blocks import LineLink, NoLink if TYPE_CHECKING: @@ -20,7 +21,7 @@ class InlineLink(MroProjection[Content]): @handles(LineLink) def linked(self, link: LineLink, part: Content) -> Content: - return part.stylize(Style.from_meta({"@click": link.click})) + return part.stylize(Style.from_meta({"@click": OpenLineLink(link)})) @handles(NoLink) def plain(self, link: NoLink, part: Content) -> Content: diff --git a/src/toad/widgets/linked_rows.py b/src/toad/widgets/linked_rows.py index d1ad27eb2..0cd05a2a9 100644 --- a/src/toad/widgets/linked_rows.py +++ b/src/toad/widgets/linked_rows.py @@ -17,6 +17,7 @@ from textual.strip import Strip from textual.visual import Visual from textual.widget import Widget +from textual.widgets._markdown import OpensMarkdownLinks from toad.app_node import FollowsTheme from toad.block_navigation import ConversationEntry @@ -61,12 +62,9 @@ def reference_context_changed(self, event: LinkContextChanged): self.invalidate_lines() -class OpensLinks(CoreEventReceiver, MroDispatch): - """A widget whose drawn links open what they name: one handler per kind of link.""" - - def action_open(self, link: LineLink) -> None: - """What a click on a drawn link runs (`LineLink.click`); the keyboard calls it too.""" - self.dispatch_sync(link) +class OpensLinks(OpensMarkdownLinks, CoreEventReceiver, MroDispatch): + """A widget whose drawn links open what they name: one handler per kind of link. A click + runs the link itself (OpenLineLink); the keyboard dispatches the same link.""" @handles(OpenTarget) def open_target(self, link: OpenTarget) -> None: @@ -96,9 +94,9 @@ def open_recorded_reference(self, reference) -> None: def open_project_link(self, link: ProjectLink) -> None: self.run_worker(link.preview(self), group="open-link") - def action_link(self, href: str) -> None: - """Decode Markdown's transport once, then use the ordinary typed actions.""" - self.action_open(LineLink.from_href(href)) + def open_link(self, href: str) -> None: + """Markdown hands its href back as a string; decode it once into the link it carries.""" + self.dispatch_sync(LineLink.from_href(href)) class MessageRowBlock(OpensLinks): diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index 0e0fedf3b..fa6dcf41e 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -34,6 +34,7 @@ from textual.strip import Strip from ui_theme.scheme import MutedColour, ThemeColour +from toad.link_click import OpenLineLink from toad.line_blocks import ( Body, Divider, @@ -114,7 +115,7 @@ class LinkStyle(MroProjection[Style]): @handles(LineLink) def linked(self, link: LineLink) -> Style: - return Style(underline=link.underlined or None, meta={"@click": link.click}) + return Style(underline=link.underlined or None, meta={"@click": OpenLineLink(link)}) @handles(NoLink) def plain(self, link: NoLink) -> Style: diff --git a/tests/guards/test_keymap_owner.py b/tests/guards/test_keymap_owner.py index 8674f2393..2f219f28a 100644 --- a/tests/guards/test_keymap_owner.py +++ b/tests/guards/test_keymap_owner.py @@ -1,13 +1,13 @@ """Keys belong to the Action family (plans/KEYMAP-PLAN.md, wave K0). -Each measure counts one way a binding is written outside its owner. A ceiling is the count -at the head this guard landed on and only falls: a migration wave lowers it in the same -change, and nothing raises it. The second test is the registry's conflict check: an app key +Each measure is one way a binding is written outside its owner, and every measure is zero: +a key a menu cannot show, or a method a key reaches by its name, has no tolerated count. The second test is the registry's conflict check: an app key that a binding closer to the focus also claims is a conflict unless the app action yields (YieldingKey) or the claimant is a modal screen, which owns the keyboard while it is up. """ import ast import importlib +from functools import cache import inspect from pathlib import Path from typing import ClassVar, Iterator @@ -25,9 +25,8 @@ class KeymapMeasure: - """One way of writing a key outside the Action family, and its ceiling.""" + """One way of writing a key outside the Action family.""" members: ClassVar[list[type['KeymapMeasure']]] = [] - ceiling: ClassVar[int] exempt: ClassVar[frozenset[Path]] = frozenset({OWNER}) def __init_subclass__(cls, **kwargs): @@ -46,7 +45,6 @@ def lines(cls, tree: ast.Module) -> Iterator[int]: class RawBindings(KeymapMeasure): """`BINDINGS =` written by hand on a class.""" - ceiling = 4 @classmethod def lines(cls, tree): @@ -62,7 +60,6 @@ def lines(cls, tree): class HandBindings(KeymapMeasure): """`Binding(key, "action_name")` written by hand: a key tied to an action by its name.""" - ceiling = 5 @classmethod def lines(cls, tree): @@ -75,7 +72,6 @@ def lines(cls, tree): class ModeBindings(KeymapMeasure): """`ModeBinding(key, method)`: a key tied to a method object, whose qualified name is the binding id and whose subclass overrides never run. Each one becomes an Action member.""" - ceiling = 0 @classmethod def lines(cls, tree): @@ -93,24 +89,41 @@ def navigation_contract(family: type[navigation.Navigation] = navigation.Navigat yield from navigation_contract(member) +@cache +def textual_operations() -> frozenset[str]: + """Methods Textual itself declares as operations: a navigation contract's (the scroll, + cursor and list families) and those carrying their own key declaration (TextArea's). Toad + overriding one changes the operation, never a key's spelling.""" + importlib.import_module('toad.app') + def walk(cls): + for sub in cls.__subclasses__(): + yield sub + yield from walk(sub) + textual = [cls for cls in walk(DOMNode) if cls.__module__.startswith('textual.')] + return frozenset(( + *(name for cls in textual if issubclass(cls, navigation.Navigable) + for name in vars(cls) if name.startswith('action_')), + *(binding.action.__name__ for cls in textual + for binding in cls.__dict__.get('_declared_key_bindings', ())), + )) + + class ActionMethods(KeymapMeasure): - """`def action_*`: behaviour Textual finds by string name. A method a Textual navigation - family declares is a required contract its key family calls, so its implementers are not this.""" - ceiling = 9 - contract = frozenset(navigation_contract()) + """`def action_*`: behaviour Textual finds by string name, unless it is an operation Textual + declares (textual_operations).""" @classmethod def lines(cls, tree): + operations = textual_operations() for node in ast.walk(tree): match node: case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name) if ( - name.startswith('action_') and name not in cls.contract): + name.startswith('action_') and name not in operations): yield node.lineno class KeyHandlers(KeymapMeasure): """An `on_key` handler deciding by the pressed key: bindings no menu can show.""" - ceiling = 1 exempt = frozenset({OWNER, *CONTRACT_HANDLERS}) @classmethod @@ -124,7 +137,6 @@ def lines(cls, tree): class VimFlagReads(KeymapMeasure): """The `vim_keys` flag read outside the preference store: a mode decided by a bool.""" - ceiling = 0 exempt = frozenset({PREFERENCES}) @classmethod @@ -137,7 +149,6 @@ def lines(cls, tree): class MenuOrders(KeymapMeasure): """`menu_order`: a hand-numbered rank where declaration order already orders.""" - ceiling = 0 exempt = frozenset() @classmethod @@ -151,7 +162,6 @@ def lines(cls, tree): class KeyLiterals(KeymapMeasure): """A key spelled outside an action's default and the preference store: compared with an event's key, given as a `key_display`, or kept in a `*_KEYS` constant.""" - ceiling = 0 exempt = frozenset({OWNER, PREFERENCES, *CONTRACT_HANDLERS}) @staticmethod @@ -175,11 +185,10 @@ def lines(cls, tree): yield node.lineno -def test_keys_written_outside_the_action_family_only_fall(): - over = {measure.__name__: (len(sites), measure.ceiling, sites[:5]) - for measure in KeymapMeasure.members - if len(sites := measure.sites()) > measure.ceiling} - assert not over, over +def test_no_key_is_written_outside_the_action_family(): + found = {measure.__name__: sites[:5] for measure in KeymapMeasure.members + if (sites := measure.sites())} + assert not found, found def declared_nodes() -> Iterator[type[DOMNode]]: From 13ad28f3c845415cb319781bf74256ccdcb26840 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 00:47:10 -0500 Subject: [PATCH 2/3] Links open in one place: the guard names open_link, the contract they open through Co-Authored-By: Claude Opus 5.5 --- tests/guards/test_deleted_interface_mechanisms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/guards/test_deleted_interface_mechanisms.py b/tests/guards/test_deleted_interface_mechanisms.py index 32f17ed83..7ea083204 100644 --- a/tests/guards/test_deleted_interface_mechanisms.py +++ b/tests/guards/test_deleted_interface_mechanisms.py @@ -54,7 +54,7 @@ def test_a_bus_message_has_one_row_drawing_and_links_open_in_one_place(): assert 'render' not in vars(MessageDivider) and issubclass(MessageDivider, OpensLinks) # The message owns its menu and one class runs it: a view has no open actions or menu entries of its own. - for name, home in (('action_link', 'widgets/linked_rows.py'), ('opens', 'project_link.py')): + for name, home in (('open_link', 'widgets/linked_rows.py'), ('opens', 'project_link.py')): assert declared[name] == [home], (name, declared[name]) assert declared['OpenSenderCommand'] == declared['OpenDestinationCommand'] == ['menus.py'] From 8a38d6e04adb0c33601d70faea2d54e1346e0d98 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 00:52:34 -0500 Subject: [PATCH 3/3] A right-click is the link action's own; link_click has no type tests or hidden imports The NRA gate on this PR found five: link_of matched OpenLineLink by type (OwnTypeTest) on a StyleAction | None (NoneAbsence), two TYPE_CHECKING imports with no cycle, the app's nested right-click if (SIM102), and the new file unformatted. With textual#245, OpenLineLink.secondary opens the link's menu and OpensLinks.link_menu decodes a toad-link href into its link's menu; the app asks handler.action.secondary once. link_of is deleted. Co-Authored-By: Claude Opus 5.5 --- src/toad/app.py | 9 ++++----- src/toad/link_click.py | 28 ++++++++++------------------ src/toad/widgets/linked_rows.py | 3 +++ 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/toad/app.py b/src/toad/app.py index 84d8338ab..a28965b18 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -3,7 +3,6 @@ from toad.workspace_sessions import WorkspaceSessionShutdown from inspect import isabstract from toad.comms_root import CoreConnection -from toad.link_click import OpenLineLink from toad.thread_actions import ThreadActions from toad.widgets.comms_transfer import Transfers @@ -493,10 +492,10 @@ async def on_text_selected(self) -> None: ) async def dispatch_style_action(self, handler, event, default_namespace) -> bool: - """Right-click adds its menu to the original decoded link action.""" - if isinstance(event, events.Click) and event.button == 3: - if await OpenLineLink.link_of(handler.action).copy_menu(default_namespace, event): - return True + """A right-click offers the drawn action's menu before the click's own action.""" + if (isinstance(event, events.Click) and event.button == 3 + and await handler.action.secondary(self, default_namespace, event)): + return True return await super().dispatch_style_action(handler, event, default_namespace) def _set_mouse_over(self, widget, hover_widget) -> None: diff --git a/src/toad/link_click.py b/src/toad/link_click.py index ad02524b8..684f7a113 100644 --- a/src/toad/link_click.py +++ b/src/toad/link_click.py @@ -1,18 +1,16 @@ """The Textual frontend's click on a drawn link: the link itself rides in the style's meta.""" + from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import cast from metaclass_registry.mro_dispatch import MroDispatch from textual.actions import StyleAction -from textual.widgets._markdown import MarkdownLink - -from toad.line_blocks import LineLink, NoLink +from textual.app import App +from textual.dom import DOMNode -if TYPE_CHECKING: - from textual.app import App - from textual.dom import DOMNode +from toad.line_blocks import LineLink @dataclass(frozen=True) @@ -21,16 +19,10 @@ class OpenLineLink(StyleAction): link: LineLink - async def run(self, app: App, namespace: DOMNode) -> None: + async def run(self, app: App, namespace: DOMNode) -> bool: cast(MroDispatch, namespace).dispatch_sync(self.link) + return True - @staticmethod - def link_of(action: StyleAction | None) -> LineLink: - """The link a decoded style action opens, for a right-click's menu; a Markdown href - carries a link through markdown-it's string-only attributes. Anything else leads nowhere.""" - match action: - case OpenLineLink(link=link): - return link - case MarkdownLink(href=href) if href.startswith(LineLink.href_scheme): - return LineLink.from_href(href) - return NoLink() + async def secondary(self, app: App, namespace: DOMNode, event: object) -> bool: + """A right-click offers the link's own menu.""" + return await self.link.copy_menu(namespace, event) diff --git a/src/toad/widgets/linked_rows.py b/src/toad/widgets/linked_rows.py index 0cd05a2a9..190e780e2 100644 --- a/src/toad/widgets/linked_rows.py +++ b/src/toad/widgets/linked_rows.py @@ -98,6 +98,9 @@ def open_link(self, href: str) -> None: """Markdown hands its href back as a string; decode it once into the link it carries.""" self.dispatch_sync(LineLink.from_href(href)) + async def link_menu(self, href: str, event: object) -> bool: + return await LineLink.from_href(href).copy_menu(self, event) + class MessageRowBlock(OpensLinks): """A widget that is one bus message under the cursor: it offers the message's menu."""