Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/textual/_event_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ class HandlerArguments(NamedTuple):
"""Information for event handler."""

modifiers: set[str]
action: actions.ActionParseResult | None
action: actions.StyleAction | None

@classmethod
def decode(cls, modifiers: set[str], value: Any) -> HandlerArguments:
if isinstance(value, str):
action = actions.parse(value)
"""The one boundary where a style's handler meta becomes an action."""
if isinstance(value, actions.StyleAction):
action: actions.StyleAction | None = value
elif isinstance(value, str):
action = actions.ParsedAction(actions.parse(value))
elif isinstance(value, tuple) and len(value) == 2:
name, parameters = value
namespace, parsed_name, _ = actions.parse(name)
action = namespace, parsed_name, parameters
action = actions.ParsedAction((namespace, parsed_name, parameters))
else:
action = None
return cls(modifiers, action)
Expand All @@ -32,7 +35,7 @@ async def run(self, app, default_namespace) -> bool:
if app.debug:
log.warning("Can't parse action from style meta; check your console markup syntax")
return False
await app.run_action(self.action, default_namespace)
await self.action.run(app, default_namespace)
return True


Expand Down
30 changes: 29 additions & 1 deletion src/textual/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,43 @@

import ast
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import lru_cache
from typing import Any
from typing import TYPE_CHECKING, Any

from typing_extensions import TypeAlias

ActionParseResult: TypeAlias = "tuple[str, str, tuple[object, ...]]"
"""An action is its name and the arbitrary tuple of its arguments."""


if TYPE_CHECKING:
from textual.app import App
from textual.dom import DOMNode


class StyleAction(ABC):
"""What a style's handler meta carries: an object that runs on the widget that drew it.

A widget that owns the operation puts the operation itself in the meta; only the string
form below goes back through the app's action names.
"""

@abstractmethod
async def run(self, app: App, namespace: DOMNode) -> None: ...


@dataclass(frozen=True)
class ParsedAction(StyleAction):
"""An action written as a name, as console markup and older widgets still write it."""

parsed: ActionParseResult

async def run(self, app: App, namespace: DOMNode) -> None:
await app.run_action(self.parsed, namespace)


class SkipAction(Exception):
"""Raise in an action to skip the action (and allow any parent bindings to run)."""

Expand Down
34 changes: 2 additions & 32 deletions src/textual/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@

from __future__ import annotations

from typing import ClassVar

from textual.binding import Binding, BindingType
from textual.layout import Layout
from textual.navigation import ScrollCommand
from textual.layouts.grid import GridLayout
from textual.layouts.horizontal import HorizontalLayout
from textual.reactive import reactive
Expand All @@ -29,7 +27,7 @@ class Container(Widget):
"""


class ScrollableContainer(Widget, can_focus=True):
class ScrollableContainer(Widget, can_focus=True, key_families=(ScrollCommand,)):
"""A scrollable container with vertical layout, and auto scrollbars on both axis."""

# We don't typically want to maximize scrollable containers,
Expand All @@ -44,34 +42,6 @@ class ScrollableContainer(Widget, can_focus=True):
}
"""

BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "scroll_up", "Scroll Up", show=False),
Binding("down", "scroll_down", "Scroll Down", show=False),
Binding("left", "scroll_left", "Scroll Left", show=False),
Binding("right", "scroll_right", "Scroll Right", show=False),
Binding("home", "scroll_home", "Scroll Home", show=False),
Binding("end", "scroll_end", "Scroll End", show=False),
Binding("pageup", "page_up", "Page Up", show=False),
Binding("pagedown", "page_down", "Page Down", show=False),
Binding("ctrl+pageup", "page_left", "Page Left", show=False),
Binding("ctrl+pagedown", "page_right", "Page Right", show=False),
]
"""Keyboard bindings for scrollable containers.

| Key(s) | Description |
| :- | :- |
| up | Scroll up, if vertical scrolling is available. |
| down | Scroll down, if vertical scrolling is available. |
| left | Scroll left, if horizontal scrolling is available. |
| right | Scroll right, if horizontal scrolling is available. |
| home | Scroll to the home position, if scrolling is available. |
| end | Scroll to the end position, if scrolling is available. |
| pageup | Scroll up one page, if vertical scrolling is available. |
| pagedown | Scroll down one page, if vertical scrolling is available. |
| ctrl+pageup | Scroll left one page, if horizontal scrolling is available. |
| ctrl+pagedown | Scroll right one page, if horizontal scrolling is available. |
"""

def __init__(
self,
*children: Widget,
Expand Down
131 changes: 127 additions & 4 deletions src/textual/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from abc import abstractmethod
from typing import ClassVar

from textual.actions import SkipAction
from textual.binding import Binding, CommandBinding
from textual.document._editing import EditingMode
from textual.dom import DOMNode
Expand Down Expand Up @@ -130,22 +131,84 @@ class Last(LastNavigation, ListCommand):
keys = ("end",)


class PageUp(ListCommand):
class ScrollCommand(NavigationCommand):
"""A key a scrolling view answers by moving its viewport."""


class ScrollUp(ScrollCommand):
keys = ("up",)

def apply(self, node: ScrollsView) -> object:
return node.action_scroll_up()


class ScrollDown(ScrollCommand):
keys = ("down",)

def apply(self, node: ScrollsView) -> object:
return node.action_scroll_down()


class ScrollLeft(ScrollCommand):
keys = ("left",)

def apply(self, node: ScrollsView) -> object:
return node.action_scroll_left()


class ScrollRight(ScrollCommand):
keys = ("right",)

def apply(self, node: ScrollsView) -> object:
return node.action_scroll_right()


class ScrollHome(ScrollCommand):
keys = ("home",)

def apply(self, node: ScrollsView) -> object:
return node.action_scroll_home()


class ScrollEnd(ScrollCommand):
keys = ("end",)

def apply(self, node: ScrollsView) -> object:
return node.action_scroll_end()


class PageUp(ListCommand, ScrollCommand):
"""A list's page and a view's page are one key and one operation."""

keys = ("pageup",)

def apply(self, node):
return node.action_page_up()


class PageDown(ListCommand):
class PageDown(ListCommand, ScrollCommand):
keys = ("pagedown",)

def apply(self, node):
return node.action_page_down()


class Navigation(DOMNode):
"""Compose navigation families with the control's other key capabilities."""
class PageLeft(ScrollCommand):
keys = ("ctrl+pageup",)

def apply(self, node: ScrollsView) -> object:
return node.action_page_left()


class PageRight(ScrollCommand):
keys = ("ctrl+pagedown",)

def apply(self, node: ScrollsView) -> object:
return node.action_page_right()


class Navigable(DOMNode):
"""A node that answers navigation commands: each command's operation runs on it."""

@property
def accepts_navigation(self) -> bool:
Expand All @@ -155,6 +218,66 @@ def apply_navigation(self, command: NavigationCommand) -> object:
return command.apply(self)


class Navigation(Navigable):
"""Compose navigation families with the control's other key capabilities."""


class ScrollsView(Navigable):
"""Every widget's viewport answers the scroll commands; a scrolling container declares
their keys (ScrollableContainer's key_families), and a subclass overrides an operation,
never a key name."""

def action_scroll_home(self) -> None:
if not self._allow_scroll:
raise SkipAction()
self.scroll_home(x_axis=self.scroll_y == 0)

def action_scroll_end(self) -> None:
if not self._allow_scroll:
raise SkipAction()
self.scroll_end(x_axis=self.scroll_y == self.is_vertical_scroll_end)

def action_scroll_left(self) -> None:
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_left()

def action_scroll_right(self) -> None:
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_right()

def action_scroll_up(self) -> None:
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_up()

def action_scroll_down(self) -> None:
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_down()

def action_page_down(self) -> None:
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_page_down()

def action_page_up(self) -> None:
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_page_up()

def action_page_left(self) -> None:
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_page_left()

def action_page_right(self) -> None:
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_page_right()


class MovesHorizontalCursor(Navigation, key_families=(HorizontalCursorCommand,)):
"""Horizontal lists share declarations while retaining native stepping."""

Expand Down
53 changes: 2 additions & 51 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
from textual.css.query import NoMatches, WrongType
from textual.css.scalar import ScalarOffset
from textual.dom import DOMNode, NoScreen
from textual.navigation import ScrollsView
from textual.geometry import (
NULL_REGION,
NULL_SIZE,
Expand Down Expand Up @@ -284,7 +285,7 @@ class BadWidgetName(Exception):


@rich.repr.auto
class Widget(DOMNode):
class Widget(ScrollsView):
"""
A Widget is the base class for Textual widgets.

Expand Down Expand Up @@ -4748,56 +4749,6 @@ async def _dispatch_message(self, message: Message) -> None:
def _on_unmount(self) -> None:
self._uncover()

def action_scroll_home(self) -> None:
if not self._allow_scroll:
raise SkipAction()
self.scroll_home(x_axis=self.scroll_y == 0)

def action_scroll_end(self) -> None:
if not self._allow_scroll:
raise SkipAction()
self.scroll_end(x_axis=self.scroll_y == self.is_vertical_scroll_end)

def action_scroll_left(self) -> None:
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_left()

def action_scroll_right(self) -> None:
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_right()

def action_scroll_up(self) -> None:
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_up()

def action_scroll_down(self) -> None:
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_down()

def action_page_down(self) -> None:
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_page_down()

def action_page_up(self) -> None:
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_page_up()

def action_page_left(self) -> None:
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_page_left()

def action_page_right(self) -> None:
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_page_right()

def notify(
self,
message: str,
Expand Down
Loading
Loading