From abeadb04d7b6198a88fb7bb1be6bdc14f3219d48 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:26:07 -0400 Subject: [PATCH 1/6] Share nominal list navigation across menus and selects --- src/textual/navigation.py | 147 ++++++++++++++++++++++++++++ src/textual/widgets/_list_view.py | 15 ++- src/textual/widgets/_option_list.py | 9 +- src/textual/widgets/_select.py | 9 +- 4 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 src/textual/navigation.py diff --git a/src/textual/navigation.py b/src/textual/navigation.py new file mode 100644 index 00000000000..d4aac3e7f70 --- /dev/null +++ b/src/textual/navigation.py @@ -0,0 +1,147 @@ +"""Shared list navigation declarations, consumed through the native keymap.""" +from __future__ import annotations + +from typing import ClassVar + +from textual.binding import Binding, CommandBinding +from textual.document._editing import EditingMode +from textual.dom import DOMNode +from textual.keys import KeyDeclaration + + +class NavigationCommand(KeyDeclaration["Navigation"]): + """A list command owns its keys; controls own the operation it requests.""" + + keys: ClassVar[tuple[str, ...]] = () + _declared: ClassVar[list[type[NavigationCommand]]] = [] + + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) + if "keys" in cls.__dict__: + NavigationCommand._declared.append(cls) + + @classmethod + def declarations(cls): + return (member for member in NavigationCommand._declared + if issubclass(member, cls)) + + @classmethod + def bindings_for(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: + return tuple( + CommandBinding(key=key, action=cls(), description=cls.title, + available=cls.available, mode=EditingMode, + id=cls.binding_id(mode), show=False, priority=True) + for key in cls.keys + ) + + @staticmethod + def available(node: Navigation) -> bool: + return node.accepts_navigation + + def __call__(self, node: Navigation) -> object: + return node.apply_navigation(self) + + def apply(self, node: Navigation) -> object: + raise NotImplementedError + + +class CursorCommand(NavigationCommand): + """Move the selected row, not an unrelated scroll container.""" + + +class CursorUp(CursorCommand): + keys = ("up", "k") + + def apply(self, node): + return node.action_cursor_up() + + +class CursorDown(CursorCommand): + keys = ("down", "j") + + def apply(self, node): + return node.action_cursor_down() + + +class ListCommand(NavigationCommand): + """Navigation through a list's ends and pages.""" + + +class First(ListCommand): + keys = ("home",) + + def apply(self, node): + return node.action_first() + + +class Last(ListCommand): + keys = ("end",) + + def apply(self, node): + return node.action_last() + + +class PageUp(ListCommand): + keys = ("pageup",) + + def apply(self, node): + return node.action_page_up() + + +class PageDown(ListCommand): + keys = ("pagedown",) + + def apply(self, node): + return node.action_page_down() + + +class Navigation(DOMNode): + """Compose navigation families with the control's other key capabilities.""" + + own_navigation: ClassVar[tuple[type[NavigationCommand], ...]] = () + + def __init_subclass__(cls, navigation: tuple[type[NavigationCommand], ...] = (), **kwargs): + cls.own_navigation = navigation + super().__init_subclass__(**kwargs) + + @classmethod + def present_navigation_binding(cls, declaration, binding): + return binding + + @classmethod + def declared_bindings(cls, mode): + return ( + *super().declared_bindings(mode), + *(cls.present_navigation_binding(declaration, binding) + for base in cls.__mro__ + for family in base.__dict__.get("own_navigation", ()) + for declaration in family.declarations() + for binding in declaration.bindings_for(mode)), + ) + + @property + def accepts_navigation(self) -> bool: + return True + + def apply_navigation(self, command: NavigationCommand) -> object: + return command.apply(self) + + +class MovesCursor(Navigation, navigation=(CursorCommand,)): + """Every selectable list shares its up/down/k/j declarations.""" + + def action_cursor_up(self) -> None: + raise NotImplementedError + + def action_cursor_down(self) -> None: + raise NotImplementedError + + +class ListNavigation(MovesCursor, navigation=(ListCommand,)): + """A full list adds end and page operations to the shared cursor keys.""" + + def action_first(self) -> None: + raise NotImplementedError + + def action_last(self) -> None: + raise NotImplementedError diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index f6e1ece4236..2b8136c776e 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -11,12 +11,13 @@ from textual.containers import VerticalScroll from textual.events import Mount from textual.message import Message +from textual.navigation import ListNavigation from textual.reactive import reactive from textual.widget import AwaitMount from textual.widgets._list_item import ListItem -class ListView(VerticalScroll, can_focus=True, can_focus_children=False): +class ListView(ListNavigation, VerticalScroll, can_focus=True, can_focus_children=False): """A vertical list view widget. Displays a vertical list of `ListItem`s which can be highlighted and @@ -62,8 +63,6 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): BINDINGS: ClassVar[list[BindingType]] = [ Binding("enter", "select_cursor", "Select", show=False), - Binding("up", "cursor_up", "Cursor up", show=False), - Binding("down", "cursor_down", "Cursor down", show=False), ] """ | Key(s) | Description | @@ -360,6 +359,16 @@ def action_select_cursor(self) -> None: return self.post_message(self.Selected(self, selected_child, self.index)) + def action_first(self) -> None: + """Highlight the first enabled item.""" + self.index = next((index for index, item in enumerate(self._nodes) + if not item.disabled), None) + + def action_last(self) -> None: + """Highlight the last enabled item.""" + self.index = next((index for index in range(len(self._nodes) - 1, -1, -1) + if not self._nodes[index].disabled), None) + def action_cursor_down(self) -> None: """Highlight the next item in the list.""" if self.index is None: diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 8c763a508a4..f2c7adb9b79 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -14,6 +14,7 @@ from textual.css.styles import RulesMap from textual.geometry import Region, Size, Spacing, clamp from textual.message import Message +from textual.navigation import ListNavigation from textual.reactive import reactive from textual.scroll_view import ScrollView from textual._paint_state import PaintState @@ -129,18 +130,12 @@ def clear(self) -> None: self.index_to_line.clear() -class OptionList(ScrollView, can_focus=True): +class OptionList(ListNavigation, ScrollView, can_focus=True): """A navigable list of options.""" ALLOW_SELECT = False BINDINGS: ClassVar[list[BindingType]] = [ - Binding("down", "cursor_down", "Down", show=False), - Binding("end", "last", "Last", show=False), Binding("enter", "select", "Select", show=False), - Binding("home", "first", "First", show=False), - Binding("pagedown", "page_down", "Page Down", show=False), - Binding("pageup", "page_up", "Page Up", show=False), - Binding("up", "cursor_up", "Up", show=False), ] """ | Key(s) | Description | diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index c7abe150134..739ffca0c51 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -14,6 +14,7 @@ from textual.containers import Horizontal, Vertical from textual.css.query import NoMatches from textual.message import Message +from textual.navigation import MovesCursor, NavigationCommand from textual.reactive import reactive, var from textual.timer import Timer from textual.widgets import Static @@ -281,7 +282,7 @@ def _on_click(self, event: events.Click) -> None: """The type used for options in the Select.""" -class Select(PrimaryActivation, Generic[SelectType], Vertical, can_focus=True): +class Select(MovesCursor, PrimaryActivation, Generic[SelectType], Vertical, can_focus=True): """Widget to select from a list of possible options. A Select displays the current selection. @@ -295,7 +296,7 @@ class Select(PrimaryActivation, Generic[SelectType], Vertical, can_focus=True): """Constant to flag that the widget has no selection.""" BINDINGS = [ - Binding("enter,down,space,up", "show_overlay", "Show menu", show=False), + Binding("enter,space", "show_overlay", "Show menu", show=False), ] """ | Key(s) | Description | @@ -684,6 +685,10 @@ def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None: def primary_activation(self): return self.action_show_overlay + def apply_navigation(self, command: NavigationCommand) -> None: + """A collapsed list admits navigation by opening its native overlay.""" + self.action_show_overlay() + def action_show_overlay(self) -> None: """Show the overlay.""" select_current = self.query_one(SelectCurrent) From 5eaa4733af1ac4715140fbb8145394d0b6d14938 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:49:12 -0400 Subject: [PATCH 2/6] Own key discovery once and share list selection across consumers --- src/textual/keys.py | 12 +++++-- src/textual/navigation.py | 49 +++++++++++++++++--------- src/textual/widgets/_list_view.py | 4 --- src/textual/widgets/_option_list.py | 8 ++--- src/textual/widgets/_select.py | 14 ++++---- src/textual/widgets/_selection_list.py | 5 ++- 6 files changed, 51 insertions(+), 41 deletions(-) diff --git a/src/textual/keys.py b/src/textual/keys.py index a285239b4eb..74efa5237bc 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -71,6 +71,12 @@ class KeyDeclaration(KeyInvocation, Generic[NodeT]): title = KeyTitle() system: ClassVar[bool] = False + _declared: ClassVar[list[type[KeyDeclaration]]] = [] + + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) + if "keys" in cls.__dict__: + KeyDeclaration._declared.append(cls) @classmethod def default_title(cls) -> str: @@ -126,10 +132,10 @@ def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: return False @classmethod - @abstractmethod def declarations(cls) -> Iterator[type[KeyDeclaration]]: - for provider in cls.__subclasses__(): - yield from provider.declarations() + """Project each key family from the declaration owner's registry.""" + return (member for member in KeyDeclaration._declared + if issubclass(member, cls)) # Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py diff --git a/src/textual/navigation.py b/src/textual/navigation.py index d4aac3e7f70..7ce65a9f582 100644 --- a/src/textual/navigation.py +++ b/src/textual/navigation.py @@ -1,36 +1,32 @@ """Shared list navigation declarations, consumed through the native keymap.""" from __future__ import annotations +from abc import abstractmethod from typing import ClassVar from textual.binding import Binding, CommandBinding from textual.document._editing import EditingMode from textual.dom import DOMNode -from textual.keys import KeyDeclaration +from textual.keys import DeclaredKey, DownKey, KeyDeclaration, UndecodedKey, UpKey class NavigationCommand(KeyDeclaration["Navigation"]): """A list command owns its keys; controls own the operation it requests.""" keys: ClassVar[tuple[str, ...]] = () - _declared: ClassVar[list[type[NavigationCommand]]] = [] - - def __init_subclass__(cls, **kwargs) -> None: - super().__init_subclass__(**kwargs) - if "keys" in cls.__dict__: - NavigationCommand._declared.append(cls) - - @classmethod - def declarations(cls): - return (member for member in NavigationCommand._declared - if issubclass(member, cls)) + priority: ClassVar[bool] = False + declared_key: ClassVar[DeclaredKey] = UndecodedKey() @classmethod def bindings_for(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: + if not cls.keys: + return () + command = cls() return tuple( - CommandBinding(key=key, action=cls(), description=cls.title, + CommandBinding(key=key, action=command, description=cls.title, available=cls.available, mode=EditingMode, - id=cls.binding_id(mode), show=False, priority=True) + id=cls.binding_id(mode), show=False, priority=cls.priority, + declared_key=cls.declared_key) for key in cls.keys ) @@ -41,16 +37,19 @@ def available(node: Navigation) -> bool: def __call__(self, node: Navigation) -> object: return node.apply_navigation(self) - def apply(self, node: Navigation) -> object: - raise NotImplementedError + @abstractmethod + def apply(self, node: Navigation) -> object: ... class CursorCommand(NavigationCommand): - """Move the selected row, not an unrelated scroll container.""" + """Move the selected row before a list's optional type-to-search handler.""" + + priority = True class CursorUp(CursorCommand): keys = ("up", "k") + declared_key = UpKey() def apply(self, node): return node.action_cursor_up() @@ -58,6 +57,7 @@ def apply(self, node): class CursorDown(CursorCommand): keys = ("down", "j") + declared_key = DownKey() def apply(self, node): return node.action_cursor_down() @@ -67,6 +67,21 @@ class ListCommand(NavigationCommand): """Navigation through a list's ends and pages.""" +class SelectionCommand(NavigationCommand): + """Activate the native control's current selection.""" + + def apply(self, node): + return node.action_select_cursor() + + +class SelectCursor(SelectionCommand, ListCommand): + keys = ("enter",) + + +class ActivateSelection(SelectionCommand): + keys = ("space",) + + class First(ListCommand): keys = ("home",) diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 2b8136c776e..44da3087ae3 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -7,7 +7,6 @@ from textual._loop import loop_from_index from textual.await_complete import AwaitComplete from textual.await_remove import AwaitRemove -from textual.binding import Binding, BindingType from textual.containers import VerticalScroll from textual.events import Mount from textual.message import Message @@ -61,9 +60,6 @@ class ListView(ListNavigation, VerticalScroll, can_focus=True, can_focus_childre } """ - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("enter", "select_cursor", "Select", show=False), - ] """ | Key(s) | Description | | :- | :- | diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index f2c7adb9b79..c556f3355c4 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -9,7 +9,6 @@ from textual import _widget_navigation, events from textual._activation import Activation from textual._loop import loop_last -from textual.binding import Binding, BindingType from textual.cache import LRUCache from textual.css.styles import RulesMap from textual.geometry import Region, Size, Spacing, clamp @@ -134,9 +133,6 @@ class OptionList(ListNavigation, ScrollView, can_focus=True): """A navigable list of options.""" ALLOW_SELECT = False - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("enter", "select", "Select", show=False), - ] """ | Key(s) | Description | | :- | :- | @@ -769,7 +765,7 @@ def select_option(self, option: Option) -> None: """Pointer and hint selection share highlight and the native action.""" if self.option_enabled(option): self.highlighted = self._option_to_index[option] - self.action_select() + self.action_select_cursor() def _get_left_gutter_width(self) -> int: """Returns the size of any left gutter that should be taken into account. @@ -1056,7 +1052,7 @@ def action_page_down(self): else: self._move_page(1) - def action_select(self) -> None: + def action_select_cursor(self) -> None: """Select the currently highlighted option. If an option is selected then a diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 739ffca0c51..513b487ba94 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -10,11 +10,12 @@ from rich.text import Text from textual import events, on -from textual.binding import Binding +from textual.binding import MethodBinding from textual.containers import Horizontal, Vertical +from textual.document._editing import EditingMode from textual.css.query import NoMatches from textual.message import Message -from textual.navigation import MovesCursor, NavigationCommand +from textual.navigation import ActivateSelection, MovesCursor, NavigationCommand, SelectCursor from textual.reactive import reactive, var from textual.timer import Timer from textual.widgets import Static @@ -51,8 +52,6 @@ class EmptySelectError(Exception): class SelectOverlay(OptionList): """The 'pop-up' overlay for the Select control.""" - BINDINGS = [("escape", "dismiss", "Dismiss menu")] - ALLOW_SELECT = False @dataclass @@ -161,6 +160,7 @@ def _find_search_match(self, query: str) -> int | None: return best_match + @MethodBinding.declare("escape", mode=EditingMode) def action_dismiss(self) -> None: """Dismiss the overlay.""" self.post_message(self.Dismiss()) @@ -282,7 +282,8 @@ def _on_click(self, event: events.Click) -> None: """The type used for options in the Select.""" -class Select(MovesCursor, PrimaryActivation, Generic[SelectType], Vertical, can_focus=True): +class Select(MovesCursor, PrimaryActivation, Generic[SelectType], Vertical, + navigation=(SelectCursor, ActivateSelection), can_focus=True): """Widget to select from a list of possible options. A Select displays the current selection. @@ -295,9 +296,6 @@ class Select(MovesCursor, PrimaryActivation, Generic[SelectType], Vertical, can_ NULL = NULL """Constant to flag that the widget has no selection.""" - BINDINGS = [ - Binding("enter,space", "show_overlay", "Show menu", show=False), - ] """ | Key(s) | Description | | :- | :- | diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 0ffd1835dbd..22589610158 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -11,7 +11,7 @@ from typing_extensions import Self from textual import events -from textual.binding import Binding +from textual.navigation import ActivateSelection from textual.content import Content, ContentText from textual.messages import Message from textual._paint_state import PaintState @@ -74,10 +74,9 @@ def initial_state(self) -> bool: return self._initial_state -class SelectionList(Generic[SelectionType], OptionList): +class SelectionList(Generic[SelectionType], OptionList, navigation=(ActivateSelection,)): """A vertical selection list that allows making multiple selections.""" - BINDINGS = [Binding("space", "select", "Toggle option", show=False)] """ | Key(s) | Description | | :- | :- | From c8b2b33fb37229a34862a67bd4d5f3cf2a6576ae Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:24:38 -0400 Subject: [PATCH 3/6] Move native controls and Tree onto declared keys --- src/textual/widgets/_button.py | 5 ++-- src/textual/widgets/_collapsible.py | 7 ++--- src/textual/widgets/_link.py | 8 ++++-- src/textual/widgets/_switch.py | 9 +++--- src/textual/widgets/_toggle_button.py | 7 ++--- src/textual/widgets/_tree.py | 41 +++++++-------------------- 6 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 3adc8abbb7b..91873f11073 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -15,7 +15,8 @@ from rich.style import Style -from textual.binding import Binding +from textual.binding import MethodBinding +from textual.document._editing import EditingMode from textual.content import Content, ContentText from textual.css._error_tools import friendly_list from textual.geometry import Size @@ -295,7 +296,6 @@ class Button(PrimaryActivation, Widget, can_focus=True): } """ - BINDINGS = [Binding("enter", "press", "Press button", show=False)] label: reactive[ContentText] = reactive[ContentText](Content.empty()) """The text label that appears within the button.""" @@ -452,6 +452,7 @@ def _start_active_affect(self) -> None: self.active_effect_duration, partial(self.remove_class, "-active") ) + @MethodBinding.declare("enter", mode=EditingMode) def action_press(self) -> None: """Activate a press of the button.""" if not self.has_class("-active"): diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py index bcc3745d9cf..2b74c52581e 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -4,7 +4,8 @@ from textual import events from textual.app import ComposeResult -from textual.binding import Binding +from textual.binding import MethodBinding +from textual.document._editing import EditingMode from textual.containers import Container from textual.content import Content, ContentText from textual.css.query import NoMatches @@ -43,9 +44,6 @@ class CollapsibleTitle(PrimaryActivation, Static, can_focus=True): } """ - BINDINGS = [ - Binding("enter", "toggle_collapsible", "Toggle collapsible", show=False) - ] """ | Key(s) | Description | | :- | :- | @@ -84,6 +82,7 @@ async def _on_click(self, event: events.Click) -> None: def primary_activation(self): return self.action_toggle_collapsible + @MethodBinding.declare("enter", mode=EditingMode) def action_toggle_collapsible(self) -> None: """Toggle the state of the parent collapsible.""" self.post_message(self.Toggle()) diff --git a/src/textual/widgets/_link.py b/src/textual/widgets/_link.py index d65ec320b65..8d3e3b1b540 100644 --- a/src/textual/widgets/_link.py +++ b/src/textual/widgets/_link.py @@ -1,6 +1,7 @@ from __future__ import annotations -from textual.binding import Binding +from textual.binding import MethodBinding +from textual.document._editing import EditingMode from textual.reactive import reactive from textual.widgets import Static @@ -21,7 +22,6 @@ class Link(Static, can_focus=True): } """ - BINDINGS = [Binding("enter", "open_link", "Open link")] """ | Key(s) | Description | | :- | :- | @@ -69,3 +69,7 @@ def on_click(self) -> None: def action_open_link(self) -> None: if self.url: self.app.open_url(self.url) + + action_open_link = MethodBinding( + key="enter", action=action_open_link, description="Open link", mode=EditingMode + ) diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index c9c3182d69a..23ddec9c76d 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -10,7 +10,8 @@ from textual.app import RenderResult from typing_extensions import Self -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding +from textual.document._editing import EditingMode from textual.events import Click from textual.geometry import Size from textual.message import Message @@ -22,15 +23,12 @@ class Switch(PrimaryActivation, Widget, can_focus=True): """A switch widget that represents a boolean value. - Can be toggled by clicking on it or through its [bindings][textual.widgets.Switch.BINDINGS]. + Can be toggled by clicking on it or through its keyboard bindings. The switch widget also contains [component classes][textual.widgets.Switch.COMPONENT_CLASSES] that enable more customization. """ - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("enter,space", "toggle_switch", "Toggle", show=False), - ] """ | Key(s) | Description | | :- | :- | @@ -210,6 +208,7 @@ async def _on_click(self, event: Click) -> None: def primary_activation(self): return self.action_toggle_switch + @MethodBinding.declare("enter", "space", mode=EditingMode) def action_toggle_switch(self) -> None: """Toggle the state of the switch.""" self.toggle() diff --git a/src/textual/widgets/_toggle_button.py b/src/textual/widgets/_toggle_button.py index 171789f7df4..119b255ffa7 100644 --- a/src/textual/widgets/_toggle_button.py +++ b/src/textual/widgets/_toggle_button.py @@ -11,7 +11,8 @@ from rich.console import RenderableType -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding +from textual.document._editing import EditingMode from textual.content import Content, ContentText from textual.events import Click from textual.geometry import Size @@ -34,9 +35,6 @@ class ToggleButton(PrimaryActivation, Static, can_focus=True): """ ALLOW_SELECT = False - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("enter,space", "toggle_button", "Toggle", show=False), - ] """ | Key(s) | Description | | :- | :- | @@ -253,6 +251,7 @@ def toggle(self) -> Self: def primary_activation(self): return self.action_toggle_button + @MethodBinding.declare("enter", "space", mode=EditingMode) def action_toggle_button(self) -> None: """Toggle the value of the widget when called as an action. diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 3982358d425..c38799949ff 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -13,7 +13,9 @@ from textual._immutable_sequence_view import ImmutableSequenceView from textual._loop import loop_last from textual._segment_tools import line_pad -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding +from textual.document._editing import EditingMode +from textual.navigation import MovesCursor, SelectCursor from textual.cache import LRUCache from textual.geometry import Region, Size, clamp from textual.message import Message @@ -515,7 +517,8 @@ def refresh(self) -> None: self._tree._refresh_line(self._line) -class Tree(Generic[TreeDataType], ScrollView, can_focus=True): +class Tree(MovesCursor, Generic[TreeDataType], ScrollView, can_focus=True, + navigation=(SelectCursor,)): """A widget for displaying and navigating data in a tree.""" ICON_NODE = "▶ " @@ -523,34 +526,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): ICON_NODE_EXPANDED = "▼ " """Unicode 'icon' to use for an expanded node.""" - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("shift+left", "cursor_parent", "Cursor to parent", show=False), - Binding( - "shift+right", - "cursor_parent_next_sibling", - "Cursor to next ancestor", - show=False, - ), - Binding( - "shift+up", - "cursor_previous_sibling", - "Cursor to previous sibling", - show=False, - ), - Binding( - "shift+down", - "cursor_next_sibling", - "Cursor to next sibling", - show=False, - ), - Binding("enter", "select_cursor", "Select", show=False), - Binding("space", "toggle_node", "Toggle", show=False), - Binding( - "shift+space", "toggle_expand_all", "Expand or collapse all", show=False - ), - Binding("up", "cursor_up", "Cursor Up", show=False), - Binding("down", "cursor_down", "Cursor Down", show=False), - ] """ | Key(s) | Description | | :- | :- | @@ -1514,6 +1489,7 @@ def action_scroll_end(self) -> None: self.cursor_line = self.last_line self.scroll_to_line(self.cursor_line) + @MethodBinding.declare("space", mode=EditingMode) def action_toggle_node(self) -> None: """Toggle the expanded state of the target node.""" try: @@ -1541,18 +1517,21 @@ def action_select_cursor(self) -> None: node = line.path[-1] self.post_message(Tree.NodeSelected(node)) + @MethodBinding.declare("shift+left", mode=EditingMode) def action_cursor_parent(self) -> None: """Move the cursor to the parent node.""" cursor_node = self.cursor_node if cursor_node is not None and cursor_node.parent is not None: self.move_cursor(cursor_node.parent, animate=True) + @MethodBinding.declare("shift+right", mode=EditingMode) def action_cursor_parent_next_sibling(self) -> None: """Move the cursor to the parent's next sibling.""" cursor_node = self.cursor_node if cursor_node is not None and cursor_node.parent is not None: self.move_cursor(cursor_node.parent.next_sibling, animate=True) + @MethodBinding.declare("shift+up", mode=EditingMode) def action_cursor_previous_sibling(self) -> None: """Move the cursor to previous sibling, or to the parent if there are no more siblings.""" cursor_node = self.cursor_node @@ -1563,6 +1542,7 @@ def action_cursor_previous_sibling(self) -> None: else: self.move_cursor(previous_sibling, animate=True) + @MethodBinding.declare("shift+down", mode=EditingMode) def action_cursor_next_sibling(self) -> None: """Move the cursor to the next sibling, or to the paren't sibling if there are no more siblings.""" cursor_node = self.cursor_node @@ -1575,6 +1555,7 @@ def action_cursor_next_sibling(self) -> None: else: self.move_cursor(next_sibling, animate=True) + @MethodBinding.declare("shift+space", mode=EditingMode) def action_toggle_expand_all(self) -> None: """Expand or collapse all siblings. From 90aead53401cc0e1865c7ae03fc2857859964f76 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:12:57 -0400 Subject: [PATCH 4/6] Declare Tabs keys and share RadioSet native navigation --- src/textual/widgets/_radio_set.py | 49 ++++++++++++++++++++++--------- src/textual/widgets/_tabs.py | 10 +++---- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index af0a397b873..27fe0796f33 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -2,21 +2,29 @@ from __future__ import annotations -from typing import ClassVar, Optional +from typing import Optional import rich.repr from rich.console import RenderableType from textual import _widget_navigation -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding from textual.containers import VerticalScroll +from textual.document._editing import EditingMode from textual.events import Click, Mount from textual.message import Message +from textual.navigation import ActivateSelection, MovesCursor, SelectCursor from textual.reactive import reactive, var from textual.widgets._radio_button import RadioButton -class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False): +class RadioSet( + MovesCursor, + VerticalScroll, + can_focus=True, + can_focus_children=False, + navigation=(SelectCursor, ActivateSelection), +): """Widget for grouping a collection of radio buttons into a set. When a collection of [`RadioButton`][textual.widgets.RadioButton]s are @@ -85,17 +93,12 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False): } """ - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("down,right", "next_button", "Next option", show=False), - Binding("enter,space", "toggle_button", "Toggle", show=False), - Binding("up,left", "previous_button", "Previous option", show=False), - ] """ | Key(s) | Description | | :- | :- | | enter, space | Toggle the currently-selected button. | - | left, up | Select the previous radio button in the set. | - | right, down | Select the next radio button in the set. | + | left, up, k | Select the previous radio button in the set. | + | right, down, j | Select the next radio button in the set. | """ _selected: var[int | None] = var[Optional[int]](None) @@ -188,7 +191,7 @@ def _on_mount(self, _: Mount) -> None: """Perform some processing once mounted in the DOM.""" # If there are radio buttons, select the first available one. - self.action_next_button() + self.action_cursor_down() # Get all the buttons within us; we'll be doing a couple of things # with that list. @@ -279,7 +282,7 @@ def pressed_index(self) -> int: else -1 ) - def action_previous_button(self) -> None: + def action_cursor_up(self) -> None: """Navigate to the previous button in the set. Note that this will wrap around to the end if at the start. @@ -290,7 +293,16 @@ def action_previous_button(self) -> None: direction=-1, ) - def action_next_button(self) -> None: + action_cursor_up = MethodBinding( + key="left", + action=action_cursor_up, + description="Previous option", + mode=EditingMode, + priority=True, + show=False, + ) + + def action_cursor_down(self) -> None: """Navigate to the next button in the set. Note that this will wrap around to the start if at the end. @@ -301,7 +313,16 @@ def action_next_button(self) -> None: direction=1, ) - def action_toggle_button(self) -> None: + action_cursor_down = MethodBinding( + key="right", + action=action_cursor_down, + description="Next option", + mode=EditingMode, + priority=True, + show=False, + ) + + def action_select_cursor(self) -> None: """Toggle the state of the currently-selected button.""" if self._selected is not None: button = self._nodes[self._selected] diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 803c4179233..903acc63196 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import ClassVar import rich.repr from rich.style import Style @@ -10,7 +9,8 @@ from textual import events from textual.app import ComposeResult, RenderResult from textual.await_complete import AwaitComplete -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding +from textual.document._editing import EditingMode from textual.containers import Container, Horizontal, Vertical from textual.content import Content, ContentText from textual.css.query import NoMatches @@ -244,10 +244,6 @@ class Tabs(Widget, can_focus=True): } """ - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("left", "previous_tab", "Previous tab", show=False), - Binding("right", "next_tab", "Next tab", show=False), - ] """ | Key(s) | Description | | :- | :- | @@ -733,10 +729,12 @@ def _on_resize(self): self._highlight_active(animate=False) self._scroll_active_tab() + @MethodBinding.declare("right", mode=EditingMode) def action_next_tab(self) -> None: """Make the next tab active.""" self._move_tab(+1) + @MethodBinding.declare("left", mode=EditingMode) def action_previous_tab(self) -> None: """Make the previous tab active.""" self._move_tab(-1) From be56a702991559106b36a0071eeae06a1ca84328 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:13:12 -0400 Subject: [PATCH 5/6] Share native horizontal declarations and remove competing family composition --- src/textual/binding.py | 15 +++-- src/textual/command.py | 18 +---- src/textual/dom.py | 26 ++++++-- src/textual/keys.py | 5 +- src/textual/navigation.py | 65 +++++++++++-------- src/textual/widget.py | 2 + src/textual/widgets/_button.py | 3 +- src/textual/widgets/_collapsible.py | 3 +- src/textual/widgets/_input.py | 15 ++--- src/textual/widgets/_link.py | 6 +- src/textual/widgets/_radio_set.py | 33 ++-------- src/textual/widgets/_select.py | 9 ++- src/textual/widgets/_selection_list.py | 2 +- src/textual/widgets/_switch.py | 3 +- src/textual/widgets/_tabs.py | 18 ++--- src/textual/widgets/_text_area.py | 24 +++---- src/textual/widgets/_toggle_button.py | 3 +- src/textual/widgets/_tree.py | 15 ++--- .../test_tabs_underline_animation.py | 6 +- .../snapshot_apps/quickly_change_tabs.py | 4 +- tests/test_tabbed_content.py | 8 +-- 21 files changed, 135 insertions(+), 148 deletions(-) diff --git a/src/textual/binding.py b/src/textual/binding.py index ffb1d974c70..89d796cf6bd 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -330,15 +330,22 @@ class MethodBinding(ModeBinding): selecting: tuple[str, ...] = () @classmethod - def declare(cls, *keys: str, mode: type[EditingMode], selecting: tuple[str, ...] = ()) -> Callable[[Callable[..., object]], MethodBinding]: + def declare( + cls, *keys: str, selecting: tuple[str, ...] = (), description: str = "", + priority: bool = False, available: Callable[[DOMNode], bool | None] = _always, + show: bool = False, + ) -> Callable[[Callable[..., object]], MethodBinding]: + from textual.document._editing import EditingMode + def declared(method: Callable[..., object]) -> MethodBinding: if not isinstance(method, FunctionType): raise TypeError("Native method declarations require a Python function") if iscoroutinefunction(method): raise TypeError("Pre-text editor methods must be synchronous") - return cls(key=",".join(keys), action=method, mode=mode, - description=method.__name__.removeprefix("action_").replace("_", " ").capitalize(), - tooltip=method.__doc__ or "", show=False, selecting=selecting) + return cls(key=",".join(keys), action=method, mode=EditingMode, + description=description or method.__name__.removeprefix("action_").replace("_", " ").capitalize(), + tooltip=method.__doc__ or "", show=show, selecting=selecting, + priority=priority, available=available) return declared @overload diff --git a/src/textual/command.py b/src/textual/command.py index 4aefb6ba5f4..1f1e6312be8 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -1227,11 +1227,11 @@ def _select_or_command( # If there is one option, assume the user wants to select it if command_list.option_count == 1: # Call after a short delay to provide a little visual feedback - self._action_command_list("select") + command_list.action_select_cursor() else: # The list is visible, something is highlighted, the user # made a selection "gesture"; let's go select it! - self._action_command_list("select") + command_list.action_select_cursor() else: # The list isn't visible, which means that if we have a # command... @@ -1256,18 +1256,6 @@ def _action_escape(self) -> None: self.app.post_message(CommandPalette.Closed(option_selected=False)) self.dismiss() - def _action_command_list(self, action: str) -> None: - """Pass an action on to the [`CommandList`][textual.command.CommandList]. - - Args: - action: The action to pass on to the [`CommandList`][textual.command.CommandList]. - """ - try: - command_action = getattr(self.query_one(CommandList), f"action_{action}") - except AttributeError: - return - command_action() - def _action_cursor_down(self) -> None: """Handle the cursor down action. @@ -1283,4 +1271,4 @@ def _action_cursor_down(self) -> None: commands.option_count and not commands.get_option_at_index(0).id == self._NO_MATCHES ): - self._action_command_list("cursor_down") + commands.action_cursor_down() diff --git a/src/textual/dom.py b/src/textual/dom.py index 6db781391cb..74df5c893e2 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -521,6 +521,8 @@ def _bindings(self) -> BindingsMap: return self._merged_bindings.copy() _declared_key_bindings: ClassVar[tuple[MethodBinding, ...]] = () + _own_key_families: ClassVar[tuple[type[KeyDeclaration], ...]] = () + key_families: ClassVar[tuple[type[KeyDeclaration], ...]] = () @classmethod def contexts(cls) -> tuple[type[DOMNode], ...]: @@ -545,12 +547,22 @@ def native_bindings(cls, mode: type[EditingMode]) -> tuple[ModeBinding, ...]: for binding in base.__dict__.get("MODE_BINDINGS", ()) for projected in binding.for_mode(mode)) + @classmethod + def present_binding(cls, declaration: type[KeyDeclaration], binding: Binding) -> Binding: + return binding + @classmethod def declared_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return (*cls.native_bindings(mode), - *(projected for base in cls.__mro__ - for binding in base.__dict__.get("_declared_key_bindings", ()) - for projected in binding.for_mode(mode))) + return ( + *(cls.present_binding(declaration, binding) + for family in cls.key_families + for declaration in family.declarations() + for binding in declaration.bindings_for(mode)), + *cls.native_bindings(mode), + *(projected for base in cls.__mro__ + for binding in base.__dict__.get("_declared_key_bindings", ()) + for projected in binding.for_mode(mode)), + ) @classmethod def binding_defaults(cls, mode: type[EditingMode]) -> BindingsMap: @@ -919,7 +931,13 @@ def __init_subclass__( inherit_css: bool = True, inherit_bindings: bool = True, inherit_component_classes: bool = True, + key_families: tuple[type[KeyDeclaration], ...] = (), ) -> None: + cls._own_key_families = key_families + cls.key_families = tuple(dict.fromkeys( + family for base in cls.__mro__ + for family in base.__dict__.get("_own_key_families", ()) + )) super().__init_subclass__() reactives = cls._reactives = {} diff --git a/src/textual/keys.py b/src/textual/keys.py index 74efa5237bc..39840cf3007 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -75,8 +75,7 @@ class KeyDeclaration(KeyInvocation, Generic[NodeT]): def __init_subclass__(cls, **kwargs) -> None: super().__init_subclass__(**kwargs) - if "keys" in cls.__dict__: - KeyDeclaration._declared.append(cls) + KeyDeclaration._declared.append(cls) @classmethod def default_title(cls) -> str: @@ -135,7 +134,7 @@ def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: def declarations(cls) -> Iterator[type[KeyDeclaration]]: """Project each key family from the declaration owner's registry.""" return (member for member in KeyDeclaration._declared - if issubclass(member, cls)) + if issubclass(member, cls) and not inspect.isabstract(member)) # Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py diff --git a/src/textual/navigation.py b/src/textual/navigation.py index 7ce65a9f582..93190e462c7 100644 --- a/src/textual/navigation.py +++ b/src/textual/navigation.py @@ -7,7 +7,7 @@ from textual.binding import Binding, CommandBinding from textual.document._editing import EditingMode from textual.dom import DOMNode -from textual.keys import DeclaredKey, DownKey, KeyDeclaration, UndecodedKey, UpKey +from textual.keys import DeclaredKey, DownKey, KeyDeclaration, LeftKey, RightKey, UndecodedKey, UpKey class NavigationCommand(KeyDeclaration["Navigation"]): @@ -47,7 +47,31 @@ class CursorCommand(NavigationCommand): priority = True -class CursorUp(CursorCommand): +class VerticalCursorCommand(CursorCommand): + """Move within a vertical selection without claiming horizontal keys.""" + + +class HorizontalCursorCommand(CursorCommand): + """Step a horizontal selection using the same cursor priority.""" + + +class CursorLeft(HorizontalCursorCommand): + keys = ("left",) + declared_key = LeftKey() + + def apply(self, node: MovesHorizontalCursor) -> object: + return node.action_cursor_left() + + +class CursorRight(HorizontalCursorCommand): + keys = ("right",) + declared_key = RightKey() + + def apply(self, node: MovesHorizontalCursor) -> object: + return node.action_cursor_right() + + +class CursorUp(VerticalCursorCommand): keys = ("up", "k") declared_key = UpKey() @@ -55,7 +79,7 @@ def apply(self, node): return node.action_cursor_up() -class CursorDown(CursorCommand): +class CursorDown(VerticalCursorCommand): keys = ("down", "j") declared_key = DownKey() @@ -113,27 +137,6 @@ def apply(self, node): class Navigation(DOMNode): """Compose navigation families with the control's other key capabilities.""" - own_navigation: ClassVar[tuple[type[NavigationCommand], ...]] = () - - def __init_subclass__(cls, navigation: tuple[type[NavigationCommand], ...] = (), **kwargs): - cls.own_navigation = navigation - super().__init_subclass__(**kwargs) - - @classmethod - def present_navigation_binding(cls, declaration, binding): - return binding - - @classmethod - def declared_bindings(cls, mode): - return ( - *super().declared_bindings(mode), - *(cls.present_navigation_binding(declaration, binding) - for base in cls.__mro__ - for family in base.__dict__.get("own_navigation", ()) - for declaration in family.declarations() - for binding in declaration.bindings_for(mode)), - ) - @property def accepts_navigation(self) -> bool: return True @@ -142,7 +145,17 @@ def apply_navigation(self, command: NavigationCommand) -> object: return command.apply(self) -class MovesCursor(Navigation, navigation=(CursorCommand,)): +class MovesHorizontalCursor(Navigation, key_families=(HorizontalCursorCommand,)): + """Horizontal lists share declarations while retaining native stepping.""" + + def action_cursor_left(self) -> None: + raise NotImplementedError + + def action_cursor_right(self) -> None: + raise NotImplementedError + + +class MovesCursor(Navigation, key_families=(VerticalCursorCommand,)): """Every selectable list shares its up/down/k/j declarations.""" def action_cursor_up(self) -> None: @@ -152,7 +165,7 @@ def action_cursor_down(self) -> None: raise NotImplementedError -class ListNavigation(MovesCursor, navigation=(ListCommand,)): +class ListNavigation(MovesCursor, key_families=(ListCommand,)): """A full list adds end and page operations to the shared cursor keys.""" def action_first(self) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 78932ebc314..0a9935466b4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3732,6 +3732,7 @@ def __init_subclass__( can_focus_children: bool | None = None, inherit_css: bool = True, inherit_bindings: bool = True, + **kwargs: object, ) -> None: name = cls.__name__ if not name[0].isupper() and not name.startswith("_"): @@ -3742,6 +3743,7 @@ def __init_subclass__( super().__init_subclass__( inherit_css=inherit_css, inherit_bindings=inherit_bindings, + **kwargs, ) if "_paint_only_render" not in cls.__dict__ and any( method in cls.__dict__ for method in ("render", "_render", "_render_bar") diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 91873f11073..791b3bcc4ac 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -16,7 +16,6 @@ from rich.style import Style from textual.binding import MethodBinding -from textual.document._editing import EditingMode from textual.content import Content, ContentText from textual.css._error_tools import friendly_list from textual.geometry import Size @@ -452,7 +451,7 @@ def _start_active_affect(self) -> None: self.active_effect_duration, partial(self.remove_class, "-active") ) - @MethodBinding.declare("enter", mode=EditingMode) + @MethodBinding.declare("enter") def action_press(self) -> None: """Activate a press of the button.""" if not self.has_class("-active"): diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py index 2b74c52581e..c75e9961f7c 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -5,7 +5,6 @@ from textual import events from textual.app import ComposeResult from textual.binding import MethodBinding -from textual.document._editing import EditingMode from textual.containers import Container from textual.content import Content, ContentText from textual.css.query import NoMatches @@ -82,7 +81,7 @@ async def _on_click(self, event: events.Click) -> None: def primary_activation(self): return self.action_toggle_collapsible - @MethodBinding.declare("enter", mode=EditingMode) + @MethodBinding.declare("enter") def action_toggle_collapsible(self) -> None: """Toggle the state of the parent collapsible.""" self.post_message(self.Toggle()) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 397aa8bda7f..19599160e6f 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -24,7 +24,6 @@ pass from textual.binding import Binding, BindingType, MethodBinding -from textual.document._editing import EditingMode from textual.css._error_tools import friendly_list from textual.events import Blur, Focus, Mount from textual.geometry import Offset, Region, Size, clamp @@ -827,7 +826,7 @@ def selected_text(self) -> str: start, end = sorted(self.selection) return self.value[start:end] - @MethodBinding.declare("left", mode=EditingMode, selecting=("shift+left",)) + @MethodBinding.declare("left", selecting=("shift+left",)) def action_cursor_left(self, select: bool = False) -> None: """Move the cursor one position to the left. @@ -843,7 +842,7 @@ def action_cursor_left(self, select: bool = False) -> None: else: self.cursor_position = min(start, end) - @MethodBinding.declare("right", mode=EditingMode, selecting=("shift+right",)) + @MethodBinding.declare("right", selecting=("shift+right",)) def action_cursor_right(self, select: bool = False) -> None: """Accept an auto-completion or move the cursor one position to the right. @@ -868,12 +867,12 @@ def select_all(self) -> None: self.selection = Selection(0, len(self.value)) self._suggestion = "" - @MethodBinding.declare("ctrl+shift+a", mode=EditingMode) + @MethodBinding.declare("ctrl+shift+a") def action_select_all(self) -> None: """Select all of the text in the Input.""" self.select_all() - @MethodBinding.declare("home", "ctrl+a", mode=EditingMode, selecting=("shift+home",)) + @MethodBinding.declare("home", "ctrl+a", selecting=("shift+home",)) def action_home(self, select: bool = False) -> None: """Move the cursor to the start of the input. @@ -885,7 +884,7 @@ def action_home(self, select: bool = False) -> None: else: self.cursor_position = 0 - @MethodBinding.declare("end", "ctrl+e", mode=EditingMode, selecting=("shift+end",)) + @MethodBinding.declare("end", "ctrl+e", selecting=("shift+end",)) def action_end(self, select: bool = False) -> None: """Move the cursor to the end of the input. @@ -899,7 +898,7 @@ def action_end(self, select: bool = False) -> None: _WORD_START = re.compile(r"(?<=\W)\w") - @MethodBinding.declare("ctrl+left", mode=EditingMode, selecting=("ctrl+shift+left",)) + @MethodBinding.declare("ctrl+left", selecting=("ctrl+shift+left",)) def action_cursor_left_word(self, select: bool = False) -> None: """Move the cursor left to the start of a word. @@ -926,7 +925,7 @@ def action_cursor_left_word(self, select: bool = False) -> None: else: self.cursor_position = target - @MethodBinding.declare("ctrl+right", mode=EditingMode, selecting=("ctrl+shift+right",)) + @MethodBinding.declare("ctrl+right", selecting=("ctrl+shift+right",)) def action_cursor_right_word(self, select: bool = False) -> None: """Move the cursor right to the start of a word. diff --git a/src/textual/widgets/_link.py b/src/textual/widgets/_link.py index 8d3e3b1b540..3ce713a7c78 100644 --- a/src/textual/widgets/_link.py +++ b/src/textual/widgets/_link.py @@ -1,7 +1,6 @@ from __future__ import annotations from textual.binding import MethodBinding -from textual.document._editing import EditingMode from textual.reactive import reactive from textual.widgets import Static @@ -66,10 +65,7 @@ def watch_text(self, text: str) -> None: def on_click(self) -> None: self.action_open_link() + @MethodBinding.declare("enter", description="Open link", show=True) def action_open_link(self) -> None: if self.url: self.app.open_url(self.url) - - action_open_link = MethodBinding( - key="enter", action=action_open_link, description="Open link", mode=EditingMode - ) diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 27fe0796f33..c09dad0a72a 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -8,22 +8,21 @@ from rich.console import RenderableType from textual import _widget_navigation -from textual.binding import MethodBinding from textual.containers import VerticalScroll -from textual.document._editing import EditingMode from textual.events import Click, Mount from textual.message import Message -from textual.navigation import ActivateSelection, MovesCursor, SelectCursor +from textual.navigation import ActivateSelection, MovesCursor, MovesHorizontalCursor, SelectCursor from textual.reactive import reactive, var from textual.widgets._radio_button import RadioButton class RadioSet( MovesCursor, + MovesHorizontalCursor, VerticalScroll, can_focus=True, can_focus_children=False, - navigation=(SelectCursor, ActivateSelection), + key_families=(SelectCursor, ActivateSelection), ): """Widget for grouping a collection of radio buttons into a set. @@ -93,14 +92,6 @@ class RadioSet( } """ - """ - | Key(s) | Description | - | :- | :- | - | enter, space | Toggle the currently-selected button. | - | left, up, k | Select the previous radio button in the set. | - | right, down, j | Select the next radio button in the set. | - """ - _selected: var[int | None] = var[Optional[int]](None) """The index of the currently-selected radio button.""" @@ -293,14 +284,7 @@ def action_cursor_up(self) -> None: direction=-1, ) - action_cursor_up = MethodBinding( - key="left", - action=action_cursor_up, - description="Previous option", - mode=EditingMode, - priority=True, - show=False, - ) + action_cursor_left = action_cursor_up def action_cursor_down(self) -> None: """Navigate to the next button in the set. @@ -313,14 +297,7 @@ def action_cursor_down(self) -> None: direction=1, ) - action_cursor_down = MethodBinding( - key="right", - action=action_cursor_down, - description="Next option", - mode=EditingMode, - priority=True, - show=False, - ) + action_cursor_right = action_cursor_down def action_select_cursor(self) -> None: """Toggle the state of the currently-selected button.""" diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 513b487ba94..e46e6881b75 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -12,7 +12,6 @@ from textual import events, on from textual.binding import MethodBinding from textual.containers import Horizontal, Vertical -from textual.document._editing import EditingMode from textual.css.query import NoMatches from textual.message import Message from textual.navigation import ActivateSelection, MovesCursor, NavigationCommand, SelectCursor @@ -160,7 +159,7 @@ def _find_search_match(self, query: str) -> int | None: return best_match - @MethodBinding.declare("escape", mode=EditingMode) + @MethodBinding.declare("escape") def action_dismiss(self) -> None: """Dismiss the overlay.""" self.post_message(self.Dismiss()) @@ -283,7 +282,7 @@ def _on_click(self, event: events.Click) -> None: class Select(MovesCursor, PrimaryActivation, Generic[SelectType], Vertical, - navigation=(SelectCursor, ActivateSelection), can_focus=True): + key_families=(SelectCursor, ActivateSelection), can_focus=True): """Widget to select from a list of possible options. A Select displays the current selection. @@ -683,6 +682,10 @@ def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None: def primary_activation(self): return self.action_show_overlay + @property + def accepts_navigation(self) -> bool: + return not self.expanded + def apply_navigation(self, command: NavigationCommand) -> None: """A collapsed list admits navigation by opening its native overlay.""" self.action_show_overlay() diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 22589610158..6abf2f2c69a 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -74,7 +74,7 @@ def initial_state(self) -> bool: return self._initial_state -class SelectionList(Generic[SelectionType], OptionList, navigation=(ActivateSelection,)): +class SelectionList(Generic[SelectionType], OptionList, key_families=(ActivateSelection,)): """A vertical selection list that allows making multiple selections.""" """ diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index 23ddec9c76d..94108a3e972 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -11,7 +11,6 @@ from typing_extensions import Self from textual.binding import MethodBinding -from textual.document._editing import EditingMode from textual.events import Click from textual.geometry import Size from textual.message import Message @@ -208,7 +207,7 @@ async def _on_click(self, event: Click) -> None: def primary_activation(self): return self.action_toggle_switch - @MethodBinding.declare("enter", "space", mode=EditingMode) + @MethodBinding.declare("enter", "space") def action_toggle_switch(self) -> None: """Toggle the state of the switch.""" self.toggle() diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 903acc63196..802a465c9fb 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -9,8 +9,7 @@ from textual import events from textual.app import ComposeResult, RenderResult from textual.await_complete import AwaitComplete -from textual.binding import MethodBinding -from textual.document._editing import EditingMode +from textual.navigation import MovesHorizontalCursor from textual.containers import Container, Horizontal, Vertical from textual.content import Content, ContentText from textual.css.query import NoMatches @@ -210,7 +209,7 @@ def _watch_disabled(self, disabled: bool) -> None: self.post_message(self.Disabled(self) if disabled else self.Enabled(self)) -class Tabs(Widget, can_focus=True): +class Tabs(MovesHorizontalCursor, Widget, can_focus=True): """A row of tabs.""" DEFAULT_CSS = """ @@ -244,13 +243,6 @@ class Tabs(Widget, can_focus=True): } """ - """ - | Key(s) | Description | - | :- | :- | - | left | Move to the previous tab. | - | right | Move to the next tab. | - """ - class TabError(Exception): """Exception raised when there is an error relating to tabs.""" @@ -729,13 +721,11 @@ def _on_resize(self): self._highlight_active(animate=False) self._scroll_active_tab() - @MethodBinding.declare("right", mode=EditingMode) - def action_next_tab(self) -> None: + def action_cursor_right(self) -> None: """Make the next tab active.""" self._move_tab(+1) - @MethodBinding.declare("left", mode=EditingMode) - def action_previous_tab(self) -> None: + def action_cursor_left(self) -> None: """Make the previous tab active.""" self._move_tab(-1) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 4916b3f3d57..387af67ee33 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -2207,7 +2207,7 @@ def select_line(self, index: int) -> None: self.selection = Selection((index, 0), (index, len(line))) self.record_cursor_width() - @MethodBinding.declare("f6", mode=EditingMode) + @MethodBinding.declare("f6") def action_select_line(self) -> None: """Select all the text on the current line.""" cursor_row, _ = self.cursor_location @@ -2222,7 +2222,7 @@ def select_all(self) -> None: self.selection = Selection(selection_start, selection_end) self.record_cursor_width() - @MethodBinding.declare("f7", mode=EditingMode) + @MethodBinding.declare("f7") def action_select_all(self) -> None: """Select all the text in the document.""" self.select_all() @@ -2301,7 +2301,7 @@ def cursor_at_end_of_text(self) -> bool: return self.cursor_at_last_line and self.cursor_at_end_of_line # ------ Cursor movement actions - @MethodBinding.declare("left", mode=EditingMode, selecting=("shift+left",)) + @MethodBinding.declare("left", selecting=("shift+left",)) def action_cursor_left(self, select: bool = False) -> None: """Move the cursor one location to the left. @@ -2331,7 +2331,7 @@ def get_cursor_left_location(self) -> Location: """ return self.navigator.get_location_left(self.cursor_location) - @MethodBinding.declare("right", mode=EditingMode, selecting=("shift+right",)) + @MethodBinding.declare("right", selecting=("shift+right",)) def action_cursor_right(self, select: bool = False) -> None: """Move the cursor one location to the right. @@ -2363,7 +2363,7 @@ def get_cursor_right_location(self) -> Location: """ return self.navigator.get_location_right(self.cursor_location) - @MethodBinding.declare("down", mode=EditingMode, selecting=("shift+down",)) + @MethodBinding.declare("down", selecting=("shift+down",)) def action_cursor_down(self, select: bool = False) -> None: """Move the cursor down one cell. @@ -2384,7 +2384,7 @@ def get_cursor_down_location(self) -> Location: """ return self.navigator.get_location_below(self.cursor_location) - @MethodBinding.declare("up", mode=EditingMode, selecting=("shift+up",)) + @MethodBinding.declare("up", selecting=("shift+up",)) def action_cursor_up(self, select: bool = False) -> None: """Move the cursor up one cell. @@ -2405,7 +2405,7 @@ def get_cursor_up_location(self) -> Location: """ return self.navigator.get_location_above(self.cursor_location) - @MethodBinding.declare("end", "ctrl+e", mode=EditingMode, selecting=("shift+end",)) + @MethodBinding.declare("end", "ctrl+e", selecting=("shift+end",)) def action_cursor_line_end(self, select: bool = False) -> None: """Move the cursor to the end of the line.""" if not self._has_cursor: @@ -2422,7 +2422,7 @@ def get_cursor_line_end_location(self) -> Location: """ return self.navigator.get_location_end(self.cursor_location) - @MethodBinding.declare("home", "ctrl+a", mode=EditingMode, selecting=("shift+home",)) + @MethodBinding.declare("home", "ctrl+a", selecting=("shift+home",)) def action_cursor_line_start(self, select: bool = False) -> None: """Move the cursor to the start of the line.""" if not self._has_cursor: @@ -2446,7 +2446,7 @@ def get_cursor_line_start_location(self, smart_home: bool = False) -> Location: self.cursor_location, smart_home=smart_home ) - @MethodBinding.declare("ctrl+left", mode=EditingMode, selecting=("ctrl+shift+left",)) + @MethodBinding.declare("ctrl+left", selecting=("ctrl+shift+left",)) def action_cursor_word_left(self, select: bool = False) -> None: """Move the cursor left by a single word, skipping trailing whitespace. @@ -2468,7 +2468,7 @@ def get_cursor_word_left_location(self) -> Location: """ return self.navigator.get_location_word_left(self.cursor_location) - @MethodBinding.declare("ctrl+right", mode=EditingMode, selecting=("ctrl+shift+right",)) + @MethodBinding.declare("ctrl+right", selecting=("ctrl+shift+right",)) def action_cursor_word_right(self, select: bool = False) -> None: """Move the cursor right by a single word, skipping leading whitespace.""" if not self.show_cursor: @@ -2487,7 +2487,7 @@ def get_cursor_word_right_location(self) -> Location: """ return self.navigator.get_location_word_right(self.cursor_location) - @MethodBinding.declare("pageup", mode=EditingMode) + @MethodBinding.declare("pageup") def action_cursor_page_up(self) -> None: """Move the cursor and scroll up one page.""" if not self.show_cursor: @@ -2502,7 +2502,7 @@ def action_cursor_page_up(self) -> None: self.scroll_relative(y=-height, animate=False) self.move_cursor(target) - @MethodBinding.declare("pagedown", mode=EditingMode) + @MethodBinding.declare("pagedown") def action_cursor_page_down(self) -> None: """Move the cursor and scroll down one page.""" if not self.show_cursor: diff --git a/src/textual/widgets/_toggle_button.py b/src/textual/widgets/_toggle_button.py index 119b255ffa7..791aa0a36a4 100644 --- a/src/textual/widgets/_toggle_button.py +++ b/src/textual/widgets/_toggle_button.py @@ -12,7 +12,6 @@ from rich.console import RenderableType from textual.binding import MethodBinding -from textual.document._editing import EditingMode from textual.content import Content, ContentText from textual.events import Click from textual.geometry import Size @@ -251,7 +250,7 @@ def toggle(self) -> Self: def primary_activation(self): return self.action_toggle_button - @MethodBinding.declare("enter", "space", mode=EditingMode) + @MethodBinding.declare("enter", "space") def action_toggle_button(self) -> None: """Toggle the value of the widget when called as an action. diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index c38799949ff..0971692cb19 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -14,7 +14,6 @@ from textual._loop import loop_last from textual._segment_tools import line_pad from textual.binding import MethodBinding -from textual.document._editing import EditingMode from textual.navigation import MovesCursor, SelectCursor from textual.cache import LRUCache from textual.geometry import Region, Size, clamp @@ -518,7 +517,7 @@ def refresh(self) -> None: class Tree(MovesCursor, Generic[TreeDataType], ScrollView, can_focus=True, - navigation=(SelectCursor,)): + key_families=(SelectCursor,)): """A widget for displaying and navigating data in a tree.""" ICON_NODE = "▶ " @@ -1489,7 +1488,7 @@ def action_scroll_end(self) -> None: self.cursor_line = self.last_line self.scroll_to_line(self.cursor_line) - @MethodBinding.declare("space", mode=EditingMode) + @MethodBinding.declare("space") def action_toggle_node(self) -> None: """Toggle the expanded state of the target node.""" try: @@ -1517,21 +1516,21 @@ def action_select_cursor(self) -> None: node = line.path[-1] self.post_message(Tree.NodeSelected(node)) - @MethodBinding.declare("shift+left", mode=EditingMode) + @MethodBinding.declare("shift+left") def action_cursor_parent(self) -> None: """Move the cursor to the parent node.""" cursor_node = self.cursor_node if cursor_node is not None and cursor_node.parent is not None: self.move_cursor(cursor_node.parent, animate=True) - @MethodBinding.declare("shift+right", mode=EditingMode) + @MethodBinding.declare("shift+right") def action_cursor_parent_next_sibling(self) -> None: """Move the cursor to the parent's next sibling.""" cursor_node = self.cursor_node if cursor_node is not None and cursor_node.parent is not None: self.move_cursor(cursor_node.parent.next_sibling, animate=True) - @MethodBinding.declare("shift+up", mode=EditingMode) + @MethodBinding.declare("shift+up") def action_cursor_previous_sibling(self) -> None: """Move the cursor to previous sibling, or to the parent if there are no more siblings.""" cursor_node = self.cursor_node @@ -1542,7 +1541,7 @@ def action_cursor_previous_sibling(self) -> None: else: self.move_cursor(previous_sibling, animate=True) - @MethodBinding.declare("shift+down", mode=EditingMode) + @MethodBinding.declare("shift+down") def action_cursor_next_sibling(self) -> None: """Move the cursor to the next sibling, or to the paren't sibling if there are no more siblings.""" cursor_node = self.cursor_node @@ -1555,7 +1554,7 @@ def action_cursor_next_sibling(self) -> None: else: self.move_cursor(next_sibling, animate=True) - @MethodBinding.declare("shift+space", mode=EditingMode) + @MethodBinding.declare("shift+space") def action_toggle_expand_all(self) -> None: """Expand or collapse all siblings. diff --git a/tests/animations/test_tabs_underline_animation.py b/tests/animations/test_tabs_underline_animation.py index c50409d59a8..fba55263321 100644 --- a/tests/animations/test_tabs_underline_animation.py +++ b/tests/animations/test_tabs_underline_animation.py @@ -24,7 +24,7 @@ async def test_tabs_underline_animates_on_full() -> None: async with app.run_test() as pilot: animator = app.animator animator._record_animation = animations.append - app.query_one(Tabs).action_previous_tab() + app.query_one(Tabs).action_cursor_left() await pilot.pause() assert "highlight_start" in animations assert "highlight_end" in animations @@ -40,7 +40,7 @@ async def test_tabs_underline_animates_on_basic() -> None: async with app.run_test() as pilot: animator = app.animator animator._record_animation = animations.append - app.query_one(Tabs).action_previous_tab() + app.query_one(Tabs).action_cursor_left() await pilot.pause() assert "highlight_start" in animations assert "highlight_end" in animations @@ -56,7 +56,7 @@ async def test_tabs_underline_does_not_animate_on_none() -> None: async with app.run_test() as pilot: animator = app.animator animator._record_animation = animations.append - app.query_one(Tabs).action_previous_tab() + app.query_one(Tabs).action_cursor_left() await pilot.pause() assert "highlight_start" not in animations assert "highlight_end" not in animations diff --git a/tests/snapshot_tests/snapshot_apps/quickly_change_tabs.py b/tests/snapshot_tests/snapshot_apps/quickly_change_tabs.py index d15433805e8..fad9097728a 100644 --- a/tests/snapshot_tests/snapshot_apps/quickly_change_tabs.py +++ b/tests/snapshot_tests/snapshot_apps/quickly_change_tabs.py @@ -14,8 +14,8 @@ def compose(self) -> ComposeResult: yield Label("three") def key_p(self) -> None: - self.query_one(Tabs).action_next_tab() - self.query_one(Tabs).action_next_tab() + self.query_one(Tabs).action_cursor_right() + self.query_one(Tabs).action_cursor_right() app = QuicklyChangeTabsApp() diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 57a8874ebf4..32d791ae31f 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -562,16 +562,16 @@ def on_mount(self) -> None: tabbed_conent = app.query_one(TabbedContent) tabs = app.query_one(Tabs) assert tabbed_conent.active == "tab-1" - tabs.action_next_tab() + tabs.action_cursor_right() await pilot.pause() assert tabbed_conent.active == "tab-2" - tabs.action_next_tab() + tabs.action_cursor_right() await pilot.pause() assert tabbed_conent.active == "tab-4" - tabs.action_next_tab() + tabs.action_cursor_right() await pilot.pause() assert tabbed_conent.active == "tab-2" - tabs.action_previous_tab() + tabs.action_cursor_left() await pilot.pause() assert tabbed_conent.active == "tab-4" From 512ac59d1e6c15d3006ac7bccaa38a0ef2ce5e3d Mon Sep 17 00:00:00 2001 From: toad-keymap Date: Fri, 30 Oct 2026 02:05:39 -0400 Subject: [PATCH 6/6] fix: compose palette and table navigation without priority text shortcuts --- src/textual/command.py | 85 ++++++++++++------------------ src/textual/navigation.py | 46 +++++++++++----- src/textual/widgets/_data_table.py | 41 ++++---------- 3 files changed, 79 insertions(+), 93 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index 1f1e6312be8..233da185e33 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -39,12 +39,16 @@ from typing_extensions import Final, TypeAlias from textual import on, work -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding from textual.containers import Horizontal, Vertical from textual.content import Content from textual.events import Click, Mount from textual.fuzzy import Matcher from textual.message import Message +from textual.navigation import ( + FirstNavigation, LastNavigation, Navigation, NavigationCommand, + PageDown, PageUp, VerticalCursorCommand, +) from textual.reactive import var from textual.screen import Screen, SystemModalScreen from textual.style import Style @@ -491,6 +495,16 @@ class CommandList(OptionList, can_focus=False): """ + def action_cursor_down(self) -> None: + """Open the native results before stepping their current selection.""" + palette = self.query_ancestor(CommandPalette) + if self.option_count and not palette._list_visible: + palette._list_visible = True + self.highlighted = 0 + elif self.option_count and self.get_option_at_index(0).id != palette._NO_MATCHES: + super().action_cursor_down() + + class SearchIcon(Static, inherit_css=False): """Widget for displaying a search icon before the command input.""" @@ -529,7 +543,22 @@ class CommandInput(Input): """ -class CommandPalette(SystemModalScreen[None]): +class PaletteBoundaryCommand(NavigationCommand): + """The palette's modified end keys leave ordinary input Home/End alone.""" + + +class PaletteFirst(FirstNavigation, PaletteBoundaryCommand): + keys = ("ctrl+home", "shift+home") + + +class PaletteLast(LastNavigation, PaletteBoundaryCommand): + keys = ("ctrl+end", "shift+end") + + +class CommandPalette( + Navigation, SystemModalScreen[None], + key_families=(VerticalCursorCommand, PageUp, PageDown, PaletteBoundaryCommand), +): """The Textual command palette.""" AUTO_FOCUS = "CommandInput" @@ -629,36 +658,8 @@ class CommandPalette(SystemModalScreen[None]): } """ - BINDINGS: ClassVar[list[BindingType]] = [ - Binding( - "ctrl+end, shift+end", - "command_list('last')", - "Go to bottom", - show=False, - ), - Binding( - "ctrl+home, shift+home", - "command_list('first')", - "Go to top", - show=False, - ), - Binding("down", "cursor_down", "Next command", show=False), - Binding("escape", "escape", "Exit the command palette"), - Binding("pagedown", "command_list('page_down')", "Next page", show=False), - Binding("pageup", "command_list('page_up')", "Previous page", show=False), - Binding("up", "command_list('cursor_up')", "Previous command", show=False), - ] - """ - | Key(s) | Description | - | :- | :- | - | ctrl+end, shift+end | Jump to the last available commands. | - | ctrl+home, shift+home | Jump to the first available commands. | - | down | Navigate down through the available commands. | - | escape | Exit the command palette. | - | pagedown | Navigate down a page through the available commands. | - | pageup | Navigate up a page through the available commands. | - | up | Navigate up through the available commands. | - """ + def apply_navigation(self, command: NavigationCommand) -> object: + return command.apply(self.query_one(CommandList)) run_on_select: ClassVar[bool] = True """A flag to say if a command should be run when selected by the user. @@ -1223,7 +1224,7 @@ def _select_or_command( # ...so if nothing in the list is highlighted yet... if command_list.highlighted is None: # ...cause the first completion to be highlighted. - self._action_cursor_down() + command_list.action_cursor_down() # If there is one option, assume the user wants to select it if command_list.option_count == 1: # Call after a short delay to provide a little visual feedback @@ -1250,25 +1251,9 @@ def _stop_event_leak(self, event: OptionList.OptionHighlighted) -> None: event.stop() self.app.post_message(CommandPalette.OptionHighlighted(highlighted_event=event)) + @MethodBinding.declare("escape", description="Exit the command palette", show=True) def _action_escape(self) -> None: """Handle a request to escape out of the command palette.""" self._cancel_gather_commands() self.app.post_message(CommandPalette.Closed(option_selected=False)) self.dismiss() - - def _action_cursor_down(self) -> None: - """Handle the cursor down action. - - This allows the cursor down key to either open the command list, if - it's closed but has options, or if it's open with options just - cursor through them. - """ - commands = self.query_one(CommandList) - if commands.option_count and not self._list_visible: - self._list_visible = True - commands.highlighted = 0 - elif ( - commands.option_count - and not commands.get_option_at_index(0).id == self._NO_MATCHES - ): - commands.action_cursor_down() diff --git a/src/textual/navigation.py b/src/textual/navigation.py index 93190e462c7..48dedcd981b 100644 --- a/src/textual/navigation.py +++ b/src/textual/navigation.py @@ -71,22 +71,40 @@ def apply(self, node: MovesHorizontalCursor) -> object: return node.action_cursor_right() -class CursorUp(VerticalCursorCommand): - keys = ("up", "k") +class UpNavigation(NavigationCommand): declared_key = UpKey() def apply(self, node): return node.action_cursor_up() -class CursorDown(VerticalCursorCommand): - keys = ("down", "j") +class DownNavigation(NavigationCommand): declared_key = DownKey() def apply(self, node): return node.action_cursor_down() +class CursorUp(UpNavigation, VerticalCursorCommand): + keys = ("up",) + + +class CursorDown(DownNavigation, VerticalCursorCommand): + keys = ("down",) + + +class TextCursorCommand(NavigationCommand): + """Opt-in printable list shortcuts yield to focused text consumers.""" + + +class TextCursorUp(UpNavigation, TextCursorCommand): + keys = ("k",) + + +class TextCursorDown(DownNavigation, TextCursorCommand): + keys = ("j",) + + class ListCommand(NavigationCommand): """Navigation through a list's ends and pages.""" @@ -106,20 +124,24 @@ class ActivateSelection(SelectionCommand): keys = ("space",) -class First(ListCommand): - keys = ("home",) - +class FirstNavigation(NavigationCommand): def apply(self, node): return node.action_first() -class Last(ListCommand): - keys = ("end",) - +class LastNavigation(NavigationCommand): def apply(self, node): return node.action_last() +class First(FirstNavigation, ListCommand): + keys = ("home",) + + +class Last(LastNavigation, ListCommand): + keys = ("end",) + + class PageUp(ListCommand): keys = ("pageup",) @@ -156,7 +178,7 @@ def action_cursor_right(self) -> None: class MovesCursor(Navigation, key_families=(VerticalCursorCommand,)): - """Every selectable list shares its up/down/k/j declarations.""" + """Selectable controls share arrow declarations, not printable input policy.""" def action_cursor_up(self) -> None: raise NotImplementedError @@ -165,7 +187,7 @@ def action_cursor_down(self) -> None: raise NotImplementedError -class ListNavigation(MovesCursor, key_families=(ListCommand,)): +class ListNavigation(MovesCursor, key_families=(ListCommand, TextCursorCommand)): """A full list adds end and page operations to the shared cursor keys.""" def action_first(self) -> None: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index dcb9d15545d..e5d05013c4b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -28,12 +28,13 @@ from textual._segment_tools import line_crop from textual._two_way_dict import TwoWayDict from textual._types import SegmentLines -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding from textual.cache import LRUCache from textual.color import Color from textual.coordinate import Coordinate from textual.geometry import Region, Size, Spacing, clamp from textual.message import Message +from textual.navigation import MovesCursor, MovesHorizontalCursor, PageDown, PageUp, SelectCursor from textual.reactive import Reactive from textual.render import measure from textual.renderables.styled import Styled @@ -266,40 +267,14 @@ class RowRenderables(NamedTuple): cells: list[RenderableType] -class DataTable(ScrollView, Generic[CellType], can_focus=True): +class DataTable( + MovesCursor, MovesHorizontalCursor, ScrollView, Generic[CellType], can_focus=True, + key_families=(SelectCursor, PageUp, PageDown), +): """A tabular widget that contains data.""" ALLOW_SELECT = False - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("enter", "select_cursor", "Select", show=False), - Binding("up", "cursor_up", "Cursor up", show=False), - Binding("down", "cursor_down", "Cursor down", show=False), - Binding("right", "cursor_right", "Cursor right", show=False), - Binding("left", "cursor_left", "Cursor left", show=False), - Binding("pageup", "page_up", "Page up", show=False), - Binding("pagedown", "page_down", "Page down", show=False), - Binding("ctrl+home", "scroll_top", "Top", show=False), - Binding("ctrl+end", "scroll_bottom", "Bottom", show=False), - Binding("home", "scroll_home", "Home", show=False), - Binding("end", "scroll_end", "End", show=False), - ] - """ - | Key(s) | Description | - | :- | :- | - | enter | Select cells under the cursor. | - | up | Move the cursor up. | - | down | Move the cursor down. | - | right | Move the cursor right. | - | left | Move the cursor left. | - | pageup | Move one page up. | - | pagedown | Move one page down. | - | ctrl+home | Move to the top. | - | ctrl+end | Move to the bottom. | - | home | Move to the home position (leftmost column). | - | end | Move to the end position (rightmost column). | - """ - COMPONENT_CLASSES: ClassVar[set[str]] = { "datatable--cursor", "datatable--hover", @@ -2741,6 +2716,7 @@ def action_page_right(self) -> None: self._set_hover_cursor(False) super().scroll_page_right() + @MethodBinding.declare("ctrl+home", description="Top") def action_scroll_top(self) -> None: """Move the cursor and scroll to the top.""" self._set_hover_cursor(False) @@ -2751,6 +2727,7 @@ def action_scroll_top(self) -> None: else: super().action_scroll_home() + @MethodBinding.declare("ctrl+end", description="Bottom") def action_scroll_bottom(self) -> None: """Move the cursor and scroll to the bottom.""" self._set_hover_cursor(False) @@ -2761,6 +2738,7 @@ def action_scroll_bottom(self) -> None: else: super().action_scroll_end() + @MethodBinding.declare("home", description="Home") def action_scroll_home(self) -> None: """Move the cursor and scroll to the leftmost column.""" self._set_hover_cursor(False) @@ -2770,6 +2748,7 @@ def action_scroll_home(self) -> None: else: self.scroll_x = 0 + @MethodBinding.declare("end", description="End") def action_scroll_end(self) -> None: """Move the cursor and scroll to the rightmost column.""" self._set_hover_cursor(False)