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
9 changes: 4 additions & 5 deletions src/toad/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,11 +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:
from toad.line_blocks import LineLink
if await LineLink.from_action(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:
Expand Down
19 changes: 1 addition & 18 deletions src/toad/line_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down
28 changes: 28 additions & 0 deletions src/toad/link_click.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""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 cast

from metaclass_registry.mro_dispatch import MroDispatch
from textual.actions import StyleAction
from textual.app import App
from textual.dom import DOMNode

from toad.line_blocks import LineLink


@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) -> bool:
cast(MroDispatch, namespace).dispatch_sync(self.link)
return True

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)
2 changes: 1 addition & 1 deletion src/toad/widgets/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion src/toad/widgets/inline_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
19 changes: 10 additions & 9 deletions src/toad/widgets/linked_rows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -96,9 +94,12 @@ 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))

async def link_menu(self, href: str, event: object) -> bool:
return await LineLink.from_href(href).copy_menu(self, event)


class MessageRowBlock(OpensLinks):
Expand Down
3 changes: 2 additions & 1 deletion src/toad/widgets/transcript_lines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/guards/test_deleted_interface_mechanisms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down
53 changes: 31 additions & 22 deletions tests/guards/test_keymap_owner.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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]]:
Expand Down
Loading