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..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,15 +1224,15 @@ 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 - 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... @@ -1250,37 +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_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. - - 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 - ): - self._action_command_list("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 a285239b4eb..39840cf3007 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -71,6 +71,11 @@ 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) + KeyDeclaration._declared.append(cls) @classmethod def default_title(cls) -> str: @@ -126,10 +131,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) 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 new file mode 100644 index 00000000000..48dedcd981b --- /dev/null +++ b/src/textual/navigation.py @@ -0,0 +1,197 @@ +"""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 DeclaredKey, DownKey, KeyDeclaration, LeftKey, RightKey, UndecodedKey, UpKey + + +class NavigationCommand(KeyDeclaration["Navigation"]): + """A list command owns its keys; controls own the operation it requests.""" + + keys: ClassVar[tuple[str, ...]] = () + 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=command, description=cls.title, + available=cls.available, mode=EditingMode, + id=cls.binding_id(mode), show=False, priority=cls.priority, + declared_key=cls.declared_key) + 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) + + @abstractmethod + def apply(self, node: Navigation) -> object: ... + + +class CursorCommand(NavigationCommand): + """Move the selected row before a list's optional type-to-search handler.""" + + priority = True + + +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 UpNavigation(NavigationCommand): + declared_key = UpKey() + + def apply(self, node): + return node.action_cursor_up() + + +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.""" + + +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 FirstNavigation(NavigationCommand): + def apply(self, node): + return node.action_first() + + +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",) + + 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.""" + + @property + def accepts_navigation(self) -> bool: + return True + + def apply_navigation(self, command: NavigationCommand) -> object: + return command.apply(self) + + +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,)): + """Selectable controls share arrow declarations, not printable input policy.""" + + def action_cursor_up(self) -> None: + raise NotImplementedError + + def action_cursor_down(self) -> None: + raise NotImplementedError + + +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: + raise NotImplementedError + + def action_last(self) -> None: + raise NotImplementedError 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 3adc8abbb7b..791b3bcc4ac 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -15,7 +15,7 @@ from rich.style import Style -from textual.binding import Binding +from textual.binding import MethodBinding from textual.content import Content, ContentText from textual.css._error_tools import friendly_list from textual.geometry import Size @@ -295,7 +295,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 +451,7 @@ def _start_active_affect(self) -> None: self.active_effect_duration, partial(self.remove_class, "-active") ) + @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 bcc3745d9cf..c75e9961f7c 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -4,7 +4,7 @@ from textual import events from textual.app import ComposeResult -from textual.binding import Binding +from textual.binding import MethodBinding from textual.containers import Container from textual.content import Content, ContentText from textual.css.query import NoMatches @@ -43,9 +43,6 @@ class CollapsibleTitle(PrimaryActivation, Static, can_focus=True): } """ - BINDINGS = [ - Binding("enter", "toggle_collapsible", "Toggle collapsible", show=False) - ] """ | Key(s) | Description | | :- | :- | @@ -84,6 +81,7 @@ async def _on_click(self, event: events.Click) -> None: def primary_activation(self): return self.action_toggle_collapsible + @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/_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) 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 d65ec320b65..3ce713a7c78 100644 --- a/src/textual/widgets/_link.py +++ b/src/textual/widgets/_link.py @@ -1,6 +1,6 @@ from __future__ import annotations -from textual.binding import Binding +from textual.binding import MethodBinding from textual.reactive import reactive from textual.widgets import Static @@ -21,7 +21,6 @@ class Link(Static, can_focus=True): } """ - BINDINGS = [Binding("enter", "open_link", "Open link")] """ | Key(s) | Description | | :- | :- | @@ -66,6 +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) diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index f6e1ece4236..44da3087ae3 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -7,16 +7,16 @@ 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 +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 @@ -60,11 +60,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 +355,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..c556f3355c4 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -9,11 +9,11 @@ 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 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,19 +129,10 @@ 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 | | :- | :- | @@ -774,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. @@ -1061,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/_radio_set.py b/src/textual/widgets/_radio_set.py index af0a397b873..c09dad0a72a 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -2,21 +2,28 @@ 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.containers import VerticalScroll from textual.events import Click, Mount from textual.message import Message +from textual.navigation import ActivateSelection, MovesCursor, MovesHorizontalCursor, 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, + MovesHorizontalCursor, + VerticalScroll, + can_focus=True, + can_focus_children=False, + key_families=(SelectCursor, ActivateSelection), +): """Widget for grouping a collection of radio buttons into a set. When a collection of [`RadioButton`][textual.widgets.RadioButton]s are @@ -85,19 +92,6 @@ 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. | - """ - _selected: var[int | None] = var[Optional[int]](None) """The index of the currently-selected radio button.""" @@ -188,7 +182,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 +273,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 +284,9 @@ def action_previous_button(self) -> None: direction=-1, ) - def action_next_button(self) -> None: + action_cursor_left = action_cursor_up + + 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 +297,9 @@ def action_next_button(self) -> None: direction=1, ) - def action_toggle_button(self) -> None: + action_cursor_right = action_cursor_down + + 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/_select.py b/src/textual/widgets/_select.py index c7abe150134..e46e6881b75 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -10,10 +10,11 @@ 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.css.query import NoMatches from textual.message import Message +from textual.navigation import ActivateSelection, MovesCursor, NavigationCommand, SelectCursor from textual.reactive import reactive, var from textual.timer import Timer from textual.widgets import Static @@ -50,8 +51,6 @@ class EmptySelectError(Exception): class SelectOverlay(OptionList): """The 'pop-up' overlay for the Select control.""" - BINDINGS = [("escape", "dismiss", "Dismiss menu")] - ALLOW_SELECT = False @dataclass @@ -160,6 +159,7 @@ def _find_search_match(self, query: str) -> int | None: return best_match + @MethodBinding.declare("escape") def action_dismiss(self) -> None: """Dismiss the overlay.""" self.post_message(self.Dismiss()) @@ -281,7 +281,8 @@ 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, + key_families=(SelectCursor, ActivateSelection), can_focus=True): """Widget to select from a list of possible options. A Select displays the current selection. @@ -294,9 +295,6 @@ class Select(PrimaryActivation, Generic[SelectType], Vertical, can_focus=True): NULL = NULL """Constant to flag that the widget has no selection.""" - BINDINGS = [ - Binding("enter,down,space,up", "show_overlay", "Show menu", show=False), - ] """ | Key(s) | Description | | :- | :- | @@ -684,6 +682,14 @@ 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() + def action_show_overlay(self) -> None: """Show the overlay.""" select_current = self.query_one(SelectCurrent) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 0ffd1835dbd..6abf2f2c69a 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, key_families=(ActivateSelection,)): """A vertical selection list that allows making multiple selections.""" - BINDINGS = [Binding("space", "select", "Toggle option", show=False)] """ | Key(s) | Description | | :- | :- | diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index c9c3182d69a..94108a3e972 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -10,7 +10,7 @@ from textual.app import RenderResult from typing_extensions import Self -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding from textual.events import Click from textual.geometry import Size from textual.message import Message @@ -22,15 +22,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 +207,7 @@ async def _on_click(self, event: Click) -> None: def primary_activation(self): return self.action_toggle_switch + @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 803c4179233..802a465c9fb 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,7 @@ from textual import events from textual.app import ComposeResult, RenderResult from textual.await_complete import AwaitComplete -from textual.binding import Binding, BindingType +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,17 +243,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 | - | :- | :- | - | 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.""" @@ -733,11 +721,11 @@ def _on_resize(self): self._highlight_active(animate=False) self._scroll_active_tab() - def action_next_tab(self) -> None: + def action_cursor_right(self) -> None: """Make the next tab active.""" self._move_tab(+1) - 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 171789f7df4..791aa0a36a4 100644 --- a/src/textual/widgets/_toggle_button.py +++ b/src/textual/widgets/_toggle_button.py @@ -11,7 +11,7 @@ from rich.console import RenderableType -from textual.binding import Binding, BindingType +from textual.binding import MethodBinding from textual.content import Content, ContentText from textual.events import Click from textual.geometry import Size @@ -34,9 +34,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 +250,7 @@ def toggle(self) -> Self: def primary_activation(self): return self.action_toggle_button + @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 3982358d425..0971692cb19 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -13,7 +13,8 @@ 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.navigation import MovesCursor, SelectCursor from textual.cache import LRUCache from textual.geometry import Region, Size, clamp from textual.message import Message @@ -515,7 +516,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, + key_families=(SelectCursor,)): """A widget for displaying and navigating data in a tree.""" ICON_NODE = "▶ " @@ -523,34 +525,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 +1488,7 @@ def action_scroll_end(self) -> None: self.cursor_line = self.last_line self.scroll_to_line(self.cursor_line) + @MethodBinding.declare("space") def action_toggle_node(self) -> None: """Toggle the expanded state of the target node.""" try: @@ -1541,18 +1516,21 @@ def action_select_cursor(self) -> None: node = line.path[-1] self.post_message(Tree.NodeSelected(node)) + @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") 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") 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 +1541,7 @@ def action_cursor_previous_sibling(self) -> None: else: self.move_cursor(previous_sibling, animate=True) + @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 @@ -1575,6 +1554,7 @@ def action_cursor_next_sibling(self) -> None: else: self.move_cursor(next_sibling, animate=True) + @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"