diff --git a/src/textual/_event_broker.py b/src/textual/_event_broker.py index 7f7fdcfe6b..832d550588 100644 --- a/src/textual/_event_broker.py +++ b/src/textual/_event_broker.py @@ -12,16 +12,19 @@ class HandlerArguments(NamedTuple): """Information for event handler.""" modifiers: set[str] - action: actions.ActionParseResult | None + action: actions.StyleAction | None @classmethod def decode(cls, modifiers: set[str], value: Any) -> HandlerArguments: - if isinstance(value, str): - action = actions.parse(value) + """The one boundary where a style's handler meta becomes an action.""" + if isinstance(value, actions.StyleAction): + action: actions.StyleAction | None = 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 = namespace, parsed_name, parameters + action = actions.ParsedAction((namespace, parsed_name, parameters)) else: action = None return cls(modifiers, action) @@ -32,7 +35,7 @@ async def run(self, app, default_namespace) -> bool: 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) + await self.action.run(app, default_namespace) return True diff --git a/src/textual/actions.py b/src/textual/actions.py index a7ff7bbde2..e6e9752e1b 100644 --- a/src/textual/actions.py +++ b/src/textual/actions.py @@ -2,8 +2,10 @@ import ast import re +from abc import ABC, abstractmethod +from dataclasses import dataclass from functools import lru_cache -from typing import Any +from typing import TYPE_CHECKING, Any from typing_extensions import TypeAlias @@ -11,6 +13,32 @@ """An action is its name and the arbitrary tuple of its arguments.""" +if TYPE_CHECKING: + from textual.app import App + from textual.dom import DOMNode + + +class StyleAction(ABC): + """What a style's handler meta carries: an object that runs on the widget that drew it. + + A widget that owns the operation puts the operation itself in the meta; only the string + form below goes back through the app's action names. + """ + + @abstractmethod + async def run(self, app: App, namespace: DOMNode) -> None: ... + + +@dataclass(frozen=True) +class ParsedAction(StyleAction): + """An action written as a name, as console markup and older widgets still write it.""" + + parsed: ActionParseResult + + async def run(self, app: App, namespace: DOMNode) -> None: + await app.run_action(self.parsed, namespace) + + class SkipAction(Exception): """Raise in an action to skip the action (and allow any parent bindings to run).""" diff --git a/src/textual/containers.py b/src/textual/containers.py index eeb1543adb..61b3c410d3 100644 --- a/src/textual/containers.py +++ b/src/textual/containers.py @@ -7,10 +7,8 @@ from __future__ import annotations -from typing import ClassVar - -from textual.binding import Binding, BindingType from textual.layout import Layout +from textual.navigation import ScrollCommand from textual.layouts.grid import GridLayout from textual.layouts.horizontal import HorizontalLayout from textual.reactive import reactive @@ -29,7 +27,7 @@ class Container(Widget): """ -class ScrollableContainer(Widget, can_focus=True): +class ScrollableContainer(Widget, can_focus=True, key_families=(ScrollCommand,)): """A scrollable container with vertical layout, and auto scrollbars on both axis.""" # We don't typically want to maximize scrollable containers, @@ -44,34 +42,6 @@ class ScrollableContainer(Widget, can_focus=True): } """ - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("up", "scroll_up", "Scroll Up", show=False), - Binding("down", "scroll_down", "Scroll Down", show=False), - Binding("left", "scroll_left", "Scroll Left", show=False), - Binding("right", "scroll_right", "Scroll Right", show=False), - Binding("home", "scroll_home", "Scroll Home", show=False), - Binding("end", "scroll_end", "Scroll End", show=False), - Binding("pageup", "page_up", "Page Up", show=False), - Binding("pagedown", "page_down", "Page Down", show=False), - Binding("ctrl+pageup", "page_left", "Page Left", show=False), - Binding("ctrl+pagedown", "page_right", "Page Right", show=False), - ] - """Keyboard bindings for scrollable containers. - - | Key(s) | Description | - | :- | :- | - | up | Scroll up, if vertical scrolling is available. | - | down | Scroll down, if vertical scrolling is available. | - | left | Scroll left, if horizontal scrolling is available. | - | right | Scroll right, if horizontal scrolling is available. | - | home | Scroll to the home position, if scrolling is available. | - | end | Scroll to the end position, if scrolling is available. | - | pageup | Scroll up one page, if vertical scrolling is available. | - | pagedown | Scroll down one page, if vertical scrolling is available. | - | ctrl+pageup | Scroll left one page, if horizontal scrolling is available. | - | ctrl+pagedown | Scroll right one page, if horizontal scrolling is available. | - """ - def __init__( self, *children: Widget, diff --git a/src/textual/navigation.py b/src/textual/navigation.py index b5fb37e6d8..2823aa607f 100644 --- a/src/textual/navigation.py +++ b/src/textual/navigation.py @@ -4,6 +4,7 @@ from abc import abstractmethod from typing import ClassVar +from textual.actions import SkipAction from textual.binding import Binding, CommandBinding from textual.document._editing import EditingMode from textual.dom import DOMNode @@ -130,22 +131,84 @@ class Last(LastNavigation, ListCommand): keys = ("end",) -class PageUp(ListCommand): +class ScrollCommand(NavigationCommand): + """A key a scrolling view answers by moving its viewport.""" + + +class ScrollUp(ScrollCommand): + keys = ("up",) + + def apply(self, node: ScrollsView) -> object: + return node.action_scroll_up() + + +class ScrollDown(ScrollCommand): + keys = ("down",) + + def apply(self, node: ScrollsView) -> object: + return node.action_scroll_down() + + +class ScrollLeft(ScrollCommand): + keys = ("left",) + + def apply(self, node: ScrollsView) -> object: + return node.action_scroll_left() + + +class ScrollRight(ScrollCommand): + keys = ("right",) + + def apply(self, node: ScrollsView) -> object: + return node.action_scroll_right() + + +class ScrollHome(ScrollCommand): + keys = ("home",) + + def apply(self, node: ScrollsView) -> object: + return node.action_scroll_home() + + +class ScrollEnd(ScrollCommand): + keys = ("end",) + + def apply(self, node: ScrollsView) -> object: + return node.action_scroll_end() + + +class PageUp(ListCommand, ScrollCommand): + """A list's page and a view's page are one key and one operation.""" + keys = ("pageup",) def apply(self, node): return node.action_page_up() -class PageDown(ListCommand): +class PageDown(ListCommand, ScrollCommand): keys = ("pagedown",) def apply(self, node): return node.action_page_down() -class Navigation(DOMNode): - """Compose navigation families with the control's other key capabilities.""" +class PageLeft(ScrollCommand): + keys = ("ctrl+pageup",) + + def apply(self, node: ScrollsView) -> object: + return node.action_page_left() + + +class PageRight(ScrollCommand): + keys = ("ctrl+pagedown",) + + def apply(self, node: ScrollsView) -> object: + return node.action_page_right() + + +class Navigable(DOMNode): + """A node that answers navigation commands: each command's operation runs on it.""" @property def accepts_navigation(self) -> bool: @@ -155,6 +218,66 @@ def apply_navigation(self, command: NavigationCommand) -> object: return command.apply(self) +class Navigation(Navigable): + """Compose navigation families with the control's other key capabilities.""" + + +class ScrollsView(Navigable): + """Every widget's viewport answers the scroll commands; a scrolling container declares + their keys (ScrollableContainer's key_families), and a subclass overrides an operation, + never a key name.""" + + def action_scroll_home(self) -> None: + if not self._allow_scroll: + raise SkipAction() + self.scroll_home(x_axis=self.scroll_y == 0) + + def action_scroll_end(self) -> None: + if not self._allow_scroll: + raise SkipAction() + self.scroll_end(x_axis=self.scroll_y == self.is_vertical_scroll_end) + + def action_scroll_left(self) -> None: + if not self.allow_horizontal_scroll: + raise SkipAction() + self.scroll_left() + + def action_scroll_right(self) -> None: + if not self.allow_horizontal_scroll: + raise SkipAction() + self.scroll_right() + + def action_scroll_up(self) -> None: + if not self.allow_vertical_scroll: + raise SkipAction() + self.scroll_up() + + def action_scroll_down(self) -> None: + if not self.allow_vertical_scroll: + raise SkipAction() + self.scroll_down() + + def action_page_down(self) -> None: + if not self.allow_vertical_scroll: + raise SkipAction() + self.scroll_page_down() + + def action_page_up(self) -> None: + if not self.allow_vertical_scroll: + raise SkipAction() + self.scroll_page_up() + + def action_page_left(self) -> None: + if not self.allow_horizontal_scroll: + raise SkipAction() + self.scroll_page_left() + + def action_page_right(self) -> None: + if not self.allow_horizontal_scroll: + raise SkipAction() + self.scroll_page_right() + + class MovesHorizontalCursor(Navigation, key_families=(HorizontalCursorCommand,)): """Horizontal lists share declarations while retaining native stepping.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index eb6f14201f..0e8b0912fe 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -77,6 +77,7 @@ from textual.css.query import NoMatches, WrongType from textual.css.scalar import ScalarOffset from textual.dom import DOMNode, NoScreen +from textual.navigation import ScrollsView from textual.geometry import ( NULL_REGION, NULL_SIZE, @@ -284,7 +285,7 @@ class BadWidgetName(Exception): @rich.repr.auto -class Widget(DOMNode): +class Widget(ScrollsView): """ A Widget is the base class for Textual widgets. @@ -4748,56 +4749,6 @@ async def _dispatch_message(self, message: Message) -> None: def _on_unmount(self) -> None: self._uncover() - def action_scroll_home(self) -> None: - if not self._allow_scroll: - raise SkipAction() - self.scroll_home(x_axis=self.scroll_y == 0) - - def action_scroll_end(self) -> None: - if not self._allow_scroll: - raise SkipAction() - self.scroll_end(x_axis=self.scroll_y == self.is_vertical_scroll_end) - - def action_scroll_left(self) -> None: - if not self.allow_horizontal_scroll: - raise SkipAction() - self.scroll_left() - - def action_scroll_right(self) -> None: - if not self.allow_horizontal_scroll: - raise SkipAction() - self.scroll_right() - - def action_scroll_up(self) -> None: - if not self.allow_vertical_scroll: - raise SkipAction() - self.scroll_up() - - def action_scroll_down(self) -> None: - if not self.allow_vertical_scroll: - raise SkipAction() - self.scroll_down() - - def action_page_down(self) -> None: - if not self.allow_vertical_scroll: - raise SkipAction() - self.scroll_page_down() - - def action_page_up(self) -> None: - if not self.allow_vertical_scroll: - raise SkipAction() - self.scroll_page_up() - - def action_page_left(self) -> None: - if not self.allow_horizontal_scroll: - raise SkipAction() - self.scroll_page_left() - - def action_page_right(self) -> None: - if not self.allow_horizontal_scroll: - raise SkipAction() - self.scroll_page_right() - def notify( self, message: str, diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index bc27f3f1f1..dbdb48ac5a 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -8,7 +8,7 @@ from functools import partial from pathlib import Path, PurePath from types import MethodType -from typing import AsyncIterator, Callable, Iterable, Optional, Sequence +from typing import AsyncIterator, Callable, Iterable, Optional, Sequence, cast from urllib.parse import unquote from markdown_it import MarkdownIt @@ -17,11 +17,13 @@ from typing_extensions import TypeAlias from textual._slug import TrackedSlugs, slug_for_tcss_id -from textual.app import ComposeResult +from textual.actions import StyleAction +from textual.app import App, ComposeResult from textual.await_complete import AwaitComplete from textual.containers import Horizontal, Vertical, VerticalScroll from textual.content import Content, Span from textual.css.query import NoMatches +from textual.dom import DOMNode from textual.events import Mount from textual.highlight import highlight from textual.layout import Layout @@ -213,7 +215,24 @@ def forward(self) -> bool: return False -class MarkdownBlock(Static): +class OpensMarkdownLinks(DOMNode): + """A widget that draws Markdown links: a click on one opens its href here, once.""" + + def open_link(self, href: str) -> None: + self.post_message(Markdown.LinkClicked(self.query_ancestor(Markdown), href)) + + +@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: + cast(OpensMarkdownLinks, namespace).open_link(self.href) + + +class MarkdownBlock(OpensMarkdownLinks, Static): """The base class for a Markdown Element.""" COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} @@ -297,10 +316,6 @@ async def _update_from_block(self, block: MarkdownBlock) -> None: await self.remove() await self._markdown.mount(block) - async def action_link(self, href: str) -> None: - """Called on link click.""" - self.post_message(Markdown.LinkClicked(self._markdown, href)) - def build_from_token(self, token: Token) -> None: """Build inline block content from its source token. @@ -373,14 +388,10 @@ def close_tag() -> None: elif child_type == "s_open": add_style(".s") elif child_type == "link_open": - href = child.attrs.get("href", "") - action = f"link({href!r})" - add_style(Style.from_meta({"@click": action})) + add_style(Style.from_meta({"@click": MarkdownLink(child.attrs.get("href", ""))})) elif child_type == "image": - href = child.attrs.get("src", "") alt = child.attrs.get("alt", "") - action = f"link({href!r})" - add_style(Style.from_meta({"@click": action})) + add_style(Style.from_meta({"@click": MarkdownLink(child.attrs.get("src", ""))})) add_content("🖼 ") if alt: add_content(f"({alt})") @@ -636,18 +647,14 @@ def compose(self) -> ComposeResult: self._blocks.clear() -class MarkdownTableCellContents(Static): +class MarkdownTableCellContents(OpensMarkdownLinks, Static): """Widget for table cells. A shim over a Static which responds to links. """ - async def action_link(self, href: str) -> None: - """Pass a link action on to the MarkdownTable parent.""" - self.post_message(Markdown.LinkClicked(self.query_ancestor(Markdown), href)) - -class MarkdownTableContent(Widget): +class MarkdownTableContent(OpensMarkdownLinks, Widget): """Renders a Markdown table.""" DEFAULT_LAYOUT = GridLayout @@ -743,11 +750,6 @@ async def _update_rows(self, updated_rows: list[list[Content]]) -> None: def on_mount(self) -> None: self.styles.grid_size_columns = len(self.headers) - async def action_link(self, href: str) -> None: - """Pass a link action on to the MarkdownTable parent.""" - if isinstance(self.parent, MarkdownTable): - await self.parent.action_link(href) - class MarkdownTable(MarkdownBlock): """A Table markdown Block.""" diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index ff9743cb3b..3251be6807 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -53,7 +53,7 @@ from textual import events, log from textual._cells import cell_len, cell_width_to_column_index -from textual.binding import Binding, MethodBinding +from textual.binding import MethodBinding from textual.events import Message, MouseEvent from textual.geometry import Offset, Region, Size, Spacing, clamp from textual.reactive import Reactive, reactive @@ -309,81 +309,6 @@ async def on_unmount(self) -> None: | `text-area--placeholder` | Target the placeholder text. | """ - BINDINGS = [ - # Deletion - Binding( - "backspace", - "delete_left", - "Delete character left", - show=False, - ), - Binding( - "ctrl+w,ctrl+backspace,alt+backspace", - "delete_word_left", - "Delete left to start of word", - show=False, - ), - Binding( - "delete,ctrl+d", - "delete_right", - "Delete character right", - show=False, - ), - Binding( - "alt+delete", - "delete_word_right", - "Delete right to start of word", - show=False, - ), - Binding( - "ctrl+x,super+x", - "cut", - "Cut", - show=False, - ), - Binding( - "ctrl+c,super+c", - "copy", - "Copy", - show=False, - ), - Binding( - "ctrl+v", - "paste", - "Paste", - show=False, - ), - Binding( - "ctrl+u,super+backspace", - "delete_to_start_of_line", - "Delete to line start", - show=False, - ), - Binding( - "ctrl+k", - "delete_to_end_of_line_or_delete_line", - "Delete to line end", - show=False, - ), - Binding( - "ctrl+shift+k", - "delete_line", - "Delete line", - show=False, - ), - Binding( - "ctrl+z,super+z", - "undo", - "Undo", - show=False, - ), - Binding( - "ctrl+y,super+y", - "redo", - "Redo", - show=False, - ), - ] """ | Key(s) | Description | | :- | :- | @@ -1794,6 +1719,7 @@ def _undo(self) -> None: self._undo_batch(edits) self.editing_mode.resume(self) + @MethodBinding.declare("ctrl+z", "super+z", description="Undo") def action_undo(self) -> None: """Undo the edits since the last checkpoint (the most recent batch of edits).""" self.undo() @@ -1807,6 +1733,7 @@ def _redo(self) -> None: self._redo_batch(edits) self.editing_mode.resume(self) + @MethodBinding.declare("ctrl+y", "super+y", description="Redo") def action_redo(self) -> None: """Redo the most recently undone batch of edits.""" self.redo() @@ -2676,6 +2603,7 @@ def _replace_via_keyboard( return None return self.replace(insert, start, end, maintain_selection_offset=False) + @MethodBinding.declare("backspace", description="Delete character left") def action_delete_left(self) -> None: """Deletes the character to the left of the cursor and updates the cursor location. @@ -2692,6 +2620,7 @@ def action_delete_left(self) -> None: self._delete_via_keyboard(start, end) + @MethodBinding.declare("delete", "ctrl+d", description="Delete character right") def action_delete_right(self) -> None: """Deletes the character to the right of the cursor and keeps the cursor at the same location. @@ -2707,6 +2636,7 @@ def action_delete_right(self) -> None: self._delete_via_keyboard(start, end) + @MethodBinding.declare("ctrl+shift+k", description="Delete line") def action_delete_line(self) -> None: """Deletes the lines which intersect with the selection.""" if self.read_only: @@ -2733,6 +2663,7 @@ def _delete_cursor_line(self) -> EditResult | None: self.move_cursor_relative(columns=end_column, record_width=False) return deletion + @MethodBinding.declare("ctrl+x", "super+x", description="Cut") def action_cut(self) -> None: """Cut text (remove and copy to clipboard).""" if self.read_only: @@ -2746,6 +2677,7 @@ def action_cut(self) -> None: if edit_result is not None: self.app.copy_to_clipboard(edit_result.replaced_text) + @MethodBinding.declare("ctrl+c", "super+c", description="Copy") def action_copy(self) -> None: """Copy selection to clipboard.""" selected_text = self.selected_text @@ -2754,12 +2686,14 @@ def action_copy(self) -> None: else: raise SkipAction() + @MethodBinding.declare("ctrl+v", description="Paste") def action_paste(self) -> None: """Paste from local clipboard.""" if self.read_only: return self.editing_mode.paste(self, self.app.clipboard) + @MethodBinding.declare("ctrl+u", "super+backspace", description="Delete to line start") def action_delete_to_start_of_line(self) -> None: """Deletes from the cursor location to the start of the line.""" if self.read_only: @@ -2782,26 +2716,21 @@ def action_delete_to_end_of_line(self) -> None: to_location = self.get_cursor_line_end_location() self._delete_via_keyboard(from_location, to_location) - async def action_delete_to_end_of_line_or_delete_line(self) -> None: + @MethodBinding.declare("ctrl+k", description="Delete to line end") + def action_delete_to_end_of_line_or_delete_line(self) -> None: """Deletes from the cursor location to the end of the line, or deletes the line. - The line will be deleted if the line is empty. + The line will be deleted if the line is empty; at the end of a line the kill joins + the next line to this one. """ - # Assume we're just going to delete to the end of the line. - action = "delete_to_end_of_line" if self.get_cursor_line_start_location() == self.get_cursor_line_end_location(): - # The line is empty, so we'll simply remove the line itself. - action = "delete_line" - elif ( - self.selection.start - == self.selection.end - == self.get_cursor_line_end_location() - ): - # We're at the end of the line, so the kill delete operation - # should join the next line to this. - action = "delete_right" - await self.run_action(action) + self.action_delete_line() + elif self.selection.start == self.selection.end == self.get_cursor_line_end_location(): + self.action_delete_right() + else: + self.action_delete_to_end_of_line() + @MethodBinding.declare("ctrl+w", "ctrl+backspace", "alt+backspace", description="Delete left to start of word") def action_delete_word_left(self) -> None: """Deletes the word to the left of the cursor and updates the cursor location.""" if self.cursor_at_start_of_text: @@ -2817,6 +2746,7 @@ def action_delete_word_left(self) -> None: to_location = self.get_cursor_word_left_location() self._delete_via_keyboard(self.selection.end, to_location) + @MethodBinding.declare("alt+delete", description="Delete right to start of word") def action_delete_word_right(self) -> None: """Deletes the word to the right of the cursor and keeps the cursor at the same location. diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 4e3814adef..c9c5b5bab4 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -234,7 +234,7 @@ async def test_softbreak_split_links_rendered_correctly() -> None: print(paragraph._content.spans) expected_spans = [ - Span(8, 20, Style.from_meta({"@click": "link('https://example.com')"})), + Span(8, 20, Style.from_meta({"@click": MD.MarkdownLink("https://example.com")})), ] print(expected_spans)