From 48546112f80a324c3100ff93c55ab041f042258d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 8 Sep 2022 21:11:05 +0100 Subject: [PATCH] focus level bindings --- examples/code_browser.css | 6 +-- examples/code_browser.py | 14 ++++-- src/textual/_styles_cache.py | 6 +-- src/textual/app.py | 27 ++++++----- src/textual/binding.py | 69 +++++++++++++++++++---------- src/textual/cli/previews/easing.css | 7 +-- src/textual/cli/previews/easing.py | 20 ++++++++- src/textual/dom.py | 45 ++++++++++--------- src/textual/widget.py | 19 ++++---- 9 files changed, 135 insertions(+), 78 deletions(-) diff --git a/examples/code_browser.css b/examples/code_browser.css index 413f8563f..00f62979d 100644 --- a/examples/code_browser.css +++ b/examples/code_browser.css @@ -1,14 +1,14 @@ #tree-view { display: none; scrollbar-gutter: stable; + width: auto; } CodeBrowser.-show-tree #tree-view { display: block; dock: left; - height: 100%; - width: auto; - max-width:50%; + height: 100%; + max-width: 50%; background: $surface; } diff --git a/examples/code_browser.py b/examples/code_browser.py index 14fa0216f..ca2dac027 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -2,6 +2,7 @@ import sys from rich.syntax import Syntax from rich.traceback import Traceback + from textual.app import App, ComposeResult from textual.layout import Container, Vertical from textual.reactive import Reactive @@ -9,17 +10,21 @@ from textual.widgets import DirectoryTree, Footer, Header, Static class CodeBrowser(App): + """Textual code browser app.""" + + BINDINGS = [ + ("t", "toggle_tree", "Toggle Tree"), + ("q", "quit", "Quit"), + ] show_tree = Reactive.init(True) def watch_show_tree(self, show_tree: bool) -> None: + """Called when show_tree is modified.""" self.set_class(show_tree, "-show-tree") - def on_load(self) -> None: - self.bind("t", "toggle_tree", description="Toggle Tree") - self.bind("q", "quit", description="Quit") - def compose(self) -> ComposeResult: + """Compose our UI.""" path = "./" if len(sys.argv) < 2 else sys.argv[1] yield Header() yield Container( @@ -29,6 +34,7 @@ class CodeBrowser(App): yield Footer() def on_directory_tree_file_click(self, event: DirectoryTree.FileClick) -> None: + """Called when the user click a file in the directory tree.""" code_view = self.query_one("#code", Static) try: syntax = Syntax.from_path( diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index e5bf36cd9..f11e865d8 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -95,15 +95,15 @@ class StylesCache: Lines: Rendered lines. """ base_background, background = widget.background_colors - padding = widget.styles.padding + widget.scrollbar_gutter + styles = widget.styles lines = self.render( - widget.styles, + styles, widget.region.size, base_background, background, widget.render_line, content_size=widget.content_region.size, - padding=padding, + padding=styles.padding, crop=crop, ) return lines diff --git a/src/textual/app.py b/src/textual/app.py index 395df3920..b756e2e03 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -191,7 +191,6 @@ class App(Generic[ReturnType], DOMNode): self._animator = Animator(self) self.animate = self._animator.bind(self) self.mouse_position = Offset(0, 0) - self.bindings = Bindings() if title is None: self._title = f"{self.__class__.__name__}" else: @@ -199,7 +198,7 @@ class App(Generic[ReturnType], DOMNode): self._logger = Logger() - self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) + self._bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) self._refresh_required = False self.design = DEFAULT_COLORS @@ -319,6 +318,13 @@ class App(Generic[ReturnType], DOMNode): return widgets + @property + def bindings(self) -> Bindings: + if self.focused is None: + return self._bindings + else: + return Bindings.merge(node._bindings for node in self.focused.ancestors) + def _set_active(self) -> None: """Set this app to be the currently active app.""" active_app.set(self) @@ -595,7 +601,7 @@ class App(Generic[ReturnType], DOMNode): show (bool, optional): Show key in UI. Defaults to True. key_display (str, optional): Replacement text for key, or None to use default. Defaults to None. """ - self.bindings.bind( + self._bindings.bind( keys, action, description, show=show, key_display=key_display ) @@ -1318,7 +1324,7 @@ class App(Generic[ReturnType], DOMNode): bool: True if the key was handled by a binding, otherwise False """ try: - binding = self.bindings.get_key(key) + binding = self._bindings.get_key(key) except NoBinding: return False else: @@ -1355,15 +1361,14 @@ class App(Generic[ReturnType], DOMNode): await super().on_event(event) async def action( - self, - action: str, - default_namespace: object | None = None, - modifiers: set[str] | None = None, + self, action: str, default_namespace: object | None = None ) -> None: """Perform an action. Args: action (str): Action encoded in a string. + default_namespace (object | None): Namespace to use if not provided in the action, + or None to use app. Defaults to None. """ target, params = actions.parse(action) if "." in target: @@ -1412,15 +1417,13 @@ class App(Generic[ReturnType], DOMNode): except AttributeError: return False try: - modifiers, action = extract_handler_actions(event_name, style.meta) + _modifiers, action = extract_handler_actions(event_name, style.meta) except NoHandler: return False else: event.stop() if isinstance(action, str): - await self.action( - action, default_namespace=default_namespace, modifiers=modifiers - ) + await self.action(action, default_namespace=default_namespace) elif callable(action): await action() else: diff --git a/src/textual/binding.py b/src/textual/binding.py index eeaf7e22e..92d8c8deb 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -1,5 +1,18 @@ from __future__ import annotations + +import sys +import rich.repr + from dataclasses import dataclass +from typing import Iterable, MutableMapping + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias + + +BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]" class NoBinding(Exception): @@ -11,16 +24,46 @@ class Binding: key: str action: str description: str - show: bool = False + show: bool = True key_display: str | None = None allow_forward: bool = True +@rich.repr.auto class Bindings: """Manage a set of bindings.""" - def __init__(self) -> None: - self.keys: dict[str, Binding] = {} + def __init__(self, bindings: Iterable[BindingType] | None = None) -> None: + def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]: + for binding in bindings: + if isinstance(binding, Binding): + yield binding + else: + yield Binding(*binding) + + self.keys: MutableMapping[str, Binding] = ( + {binding.key: binding for binding in make_bindings(bindings)} + if bindings + else {} + ) + + def __rich_repr__(self) -> rich.repr.Result: + yield self.keys + + @classmethod + def merge(cls, bindings: Iterable[Bindings]) -> Bindings: + """Merge a bindings. Subsequence bound keys override initial keys. + + Args: + bindings (Iterable[Bindings]): A number of bindings. + + Returns: + Bindings: New bindings. + """ + keys: dict[str, Binding] = {} + for _bindings in bindings: + keys |= _bindings.keys + return Bindings(keys.values()) @property def shown_keys(self) -> list[Binding]: @@ -58,23 +101,3 @@ class Bindings: if binding is None: return True return binding.allow_forward - - -class BindingStack: - """Manage a stack of bindings.""" - - def __init__(self, *bindings: Bindings) -> None: - self._stack: list[Bindings] = list(bindings) - - def push(self, bindings: Bindings) -> None: - self._stack.append(bindings) - - def pop(self) -> Bindings: - return self._stack.pop() - - def get_key(self, key: str) -> Binding: - for bindings in reversed(self._stack): - binding = bindings.keys.get(key, None) - if binding is not None: - return binding - raise NoBinding(f"No binding for {key}") from None diff --git a/src/textual/cli/previews/easing.css b/src/textual/cli/previews/easing.css index a881ece4d..7840c5590 100644 --- a/src/textual/cli/previews/easing.css +++ b/src/textual/cli/previews/easing.css @@ -27,7 +27,7 @@ Bar { #other { width: 1fr; - background: #555555; + background: $panel; padding: 1; height: 100%; border-left: vkey $background; @@ -35,6 +35,7 @@ Bar { #opacity-widget { padding: 1; - background: $panel; - border: wide #969696; + background: $warning; + color: $text-warning; + border: wide $background; } diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index b4cb08e35..5d57099f4 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -29,12 +29,30 @@ class Bar(Widget): position = Reactive.init(START_POSITION) animation_running = Reactive(False) + DEFAULT_CSS = """ + + Bar { + background: $surface; + color: $error; + } + + Bar.-active { + background: $surface; + color: $success; + } + + """ + + def watch_animation_running(self, running: bool) -> None: + self.set_class(running, "-active") + def render(self) -> RenderableType: + return ScrollBarRender( virtual_size=VIRTUAL_SIZE, window_size=WINDOW_SIZE, position=self.position, - style="green" if self.animation_running else "red", + style=self.rich_style, ) diff --git a/src/textual/dom.py b/src/textual/dom.py index e21b440a9..6cdc0451d 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -22,6 +22,7 @@ from rich.tree import Tree from ._context import NoActiveAppError from ._node_list import NodeList +from .binding import Bindings, BindingType from .color import Color, WHITE, BLACK from .css._error_tools import friendly_list from .css.constants import VALID_DISPLAY, VALID_VISIBILITY @@ -91,6 +92,9 @@ class DOMNode(MessagePump): # Virtual DOM nodes COMPONENT_CLASSES: ClassVar[set[str]] = set() + # Mapping of key bindings + BINDINGS: ClassVar[list[BindingType]] = [] + # True if this node inherits the CSS from the base class. _inherit_css: ClassVar[bool] = True # List of names of base class (lower cased) that inherit CSS @@ -123,6 +127,7 @@ class DOMNode(MessagePump): self._auto_refresh: float | None = None self._auto_refresh_timer: Timer | None = None self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)} + self._bindings = Bindings(self.BINDINGS) super().__init__() @@ -475,21 +480,25 @@ class DOMNode(MessagePump): Returns: Style: Rich Style object. """ - - # TODO: Feels like there may be opportunity for caching here. - - style = Style() - for node in reversed(self.ancestors): - style += node.styles.text_style - return style + return Style.combine( + node.styles.text_style for node in reversed(self.ancestors) + ) @property def rich_style(self) -> Style: """Get a Rich Style object for this DOMNode.""" - _, _, background, color = self.colors - style = ( - Style.from_color((background + color).rich_color, background.rich_color) - + self.text_style + background = WHITE + color = BLACK + style = Style() + for node in reversed(self.ancestors): + styles = node.styles + if styles.has_rule("background"): + background += styles.background + if styles.has_rule("color"): + color = styles.color + style += styles.text_style + style += Style.from_color( + (background + color).rich_color, background.rich_color ) return style @@ -501,9 +510,7 @@ class DOMNode(MessagePump): tuple[Color, Color]: Tuple of (base background, background) """ - base_background = background = BLACK - for node in reversed(self.ancestors): styles = node.styles if styles.has_rule("background"): @@ -533,15 +540,13 @@ class DOMNode(MessagePump): @property def ancestors(self) -> list[DOMNode]: """Get a list of Nodes by tracing ancestors all the way back to App.""" - nodes: list[DOMNode] = [self] + nodes: list[MessagePump | None] = [] add_node = nodes.append - node: DOMNode = self - while True: - node = node._parent - if node is None: - break + node: MessagePump | None = self + while node is not None: add_node(node) - return nodes + node = node._parent + return cast(list[DOMNode], nodes) @property def displayed_children(self) -> list[Widget]: diff --git a/src/textual/widget.py b/src/textual/widget.py index ea9f90554..7152d5e0a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -7,13 +7,8 @@ from operator import attrgetter from typing import TYPE_CHECKING, ClassVar, Collection, Iterable, NamedTuple import rich.repr -from rich.console import ( - Console, - ConsoleRenderable, - Measurement, - JustifyMethod, - RenderableType, -) +from rich.console import Console, ConsoleRenderable, JustifyMethod, RenderableType +from rich.measure import Measurement from rich.segment import Segment from rich.style import Style from rich.styled import Styled @@ -27,6 +22,7 @@ from ._layout import Layout from ._segment_tools import align_lines from ._styles_cache import StylesCache from ._types import Lines +from .binding import NoBinding from .box_model import BoxModel, get_box_model from .css.constants import VALID_TEXT_ALIGN from .dom import DOMNode, NoScreen @@ -522,7 +518,7 @@ class Widget(DOMNode): """Get the height used by the *horizontal* scrollbar.""" styles = self.styles if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto": - return self.styles.scrollbar_size_horizontal + return styles.scrollbar_size_horizontal return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0 @property @@ -1474,7 +1470,12 @@ class Widget(DOMNode): await self.broker_event("click", event) async def _on_key(self, event: events.Key) -> None: - await self.dispatch_key(event) + try: + binding = self._bindings.get_key(event.key) + except NoBinding: + await self.dispatch_key(event) + else: + await self.action(binding.action) def _on_mount(self, event: events.Mount) -> None: widgets = self.compose()