focus level bindings

This commit is contained in:
Will McGugan
2022-09-08 21:11:05 +01:00
parent 1d1f75e1c8
commit 48546112f8
9 changed files with 135 additions and 78 deletions

View File

@@ -1,14 +1,14 @@
#tree-view { #tree-view {
display: none; display: none;
scrollbar-gutter: stable; scrollbar-gutter: stable;
width: auto;
} }
CodeBrowser.-show-tree #tree-view { CodeBrowser.-show-tree #tree-view {
display: block; display: block;
dock: left; dock: left;
height: 100%; height: 100%;
width: auto; max-width: 50%;
max-width:50%;
background: $surface; background: $surface;
} }

View File

@@ -2,6 +2,7 @@ import sys
from rich.syntax import Syntax from rich.syntax import Syntax
from rich.traceback import Traceback from rich.traceback import Traceback
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.layout import Container, Vertical from textual.layout import Container, Vertical
from textual.reactive import Reactive from textual.reactive import Reactive
@@ -9,17 +10,21 @@ from textual.widgets import DirectoryTree, Footer, Header, Static
class CodeBrowser(App): class CodeBrowser(App):
"""Textual code browser app."""
BINDINGS = [
("t", "toggle_tree", "Toggle Tree"),
("q", "quit", "Quit"),
]
show_tree = Reactive.init(True) show_tree = Reactive.init(True)
def watch_show_tree(self, show_tree: bool) -> None: def watch_show_tree(self, show_tree: bool) -> None:
"""Called when show_tree is modified."""
self.set_class(show_tree, "-show-tree") 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: def compose(self) -> ComposeResult:
"""Compose our UI."""
path = "./" if len(sys.argv) < 2 else sys.argv[1] path = "./" if len(sys.argv) < 2 else sys.argv[1]
yield Header() yield Header()
yield Container( yield Container(
@@ -29,6 +34,7 @@ class CodeBrowser(App):
yield Footer() yield Footer()
def on_directory_tree_file_click(self, event: DirectoryTree.FileClick) -> None: 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) code_view = self.query_one("#code", Static)
try: try:
syntax = Syntax.from_path( syntax = Syntax.from_path(

View File

@@ -95,15 +95,15 @@ class StylesCache:
Lines: Rendered lines. Lines: Rendered lines.
""" """
base_background, background = widget.background_colors base_background, background = widget.background_colors
padding = widget.styles.padding + widget.scrollbar_gutter styles = widget.styles
lines = self.render( lines = self.render(
widget.styles, styles,
widget.region.size, widget.region.size,
base_background, base_background,
background, background,
widget.render_line, widget.render_line,
content_size=widget.content_region.size, content_size=widget.content_region.size,
padding=padding, padding=styles.padding,
crop=crop, crop=crop,
) )
return lines return lines

View File

@@ -191,7 +191,6 @@ class App(Generic[ReturnType], DOMNode):
self._animator = Animator(self) self._animator = Animator(self)
self.animate = self._animator.bind(self) self.animate = self._animator.bind(self)
self.mouse_position = Offset(0, 0) self.mouse_position = Offset(0, 0)
self.bindings = Bindings()
if title is None: if title is None:
self._title = f"{self.__class__.__name__}" self._title = f"{self.__class__.__name__}"
else: else:
@@ -199,7 +198,7 @@ class App(Generic[ReturnType], DOMNode):
self._logger = Logger() 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._refresh_required = False
self.design = DEFAULT_COLORS self.design = DEFAULT_COLORS
@@ -319,6 +318,13 @@ class App(Generic[ReturnType], DOMNode):
return widgets 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: def _set_active(self) -> None:
"""Set this app to be the currently active app.""" """Set this app to be the currently active app."""
active_app.set(self) active_app.set(self)
@@ -595,7 +601,7 @@ class App(Generic[ReturnType], DOMNode):
show (bool, optional): Show key in UI. Defaults to True. 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. 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 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 bool: True if the key was handled by a binding, otherwise False
""" """
try: try:
binding = self.bindings.get_key(key) binding = self._bindings.get_key(key)
except NoBinding: except NoBinding:
return False return False
else: else:
@@ -1355,15 +1361,14 @@ class App(Generic[ReturnType], DOMNode):
await super().on_event(event) await super().on_event(event)
async def action( async def action(
self, self, action: str, default_namespace: object | None = None
action: str,
default_namespace: object | None = None,
modifiers: set[str] | None = None,
) -> None: ) -> None:
"""Perform an action. """Perform an action.
Args: Args:
action (str): Action encoded in a string. 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) target, params = actions.parse(action)
if "." in target: if "." in target:
@@ -1412,15 +1417,13 @@ class App(Generic[ReturnType], DOMNode):
except AttributeError: except AttributeError:
return False return False
try: try:
modifiers, action = extract_handler_actions(event_name, style.meta) _modifiers, action = extract_handler_actions(event_name, style.meta)
except NoHandler: except NoHandler:
return False return False
else: else:
event.stop() event.stop()
if isinstance(action, str): if isinstance(action, str):
await self.action( await self.action(action, default_namespace=default_namespace)
action, default_namespace=default_namespace, modifiers=modifiers
)
elif callable(action): elif callable(action):
await action() await action()
else: else:

View File

@@ -1,5 +1,18 @@
from __future__ import annotations from __future__ import annotations
import sys
import rich.repr
from dataclasses import dataclass 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): class NoBinding(Exception):
@@ -11,16 +24,46 @@ class Binding:
key: str key: str
action: str action: str
description: str description: str
show: bool = False show: bool = True
key_display: str | None = None key_display: str | None = None
allow_forward: bool = True allow_forward: bool = True
@rich.repr.auto
class Bindings: class Bindings:
"""Manage a set of bindings.""" """Manage a set of bindings."""
def __init__(self) -> None: def __init__(self, bindings: Iterable[BindingType] | None = None) -> None:
self.keys: dict[str, Binding] = {} 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 @property
def shown_keys(self) -> list[Binding]: def shown_keys(self) -> list[Binding]:
@@ -58,23 +101,3 @@ class Bindings:
if binding is None: if binding is None:
return True return True
return binding.allow_forward 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

View File

@@ -27,7 +27,7 @@ Bar {
#other { #other {
width: 1fr; width: 1fr;
background: #555555; background: $panel;
padding: 1; padding: 1;
height: 100%; height: 100%;
border-left: vkey $background; border-left: vkey $background;
@@ -35,6 +35,7 @@ Bar {
#opacity-widget { #opacity-widget {
padding: 1; padding: 1;
background: $panel; background: $warning;
border: wide #969696; color: $text-warning;
border: wide $background;
} }

View File

@@ -29,12 +29,30 @@ class Bar(Widget):
position = Reactive.init(START_POSITION) position = Reactive.init(START_POSITION)
animation_running = Reactive(False) 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: def render(self) -> RenderableType:
return ScrollBarRender( return ScrollBarRender(
virtual_size=VIRTUAL_SIZE, virtual_size=VIRTUAL_SIZE,
window_size=WINDOW_SIZE, window_size=WINDOW_SIZE,
position=self.position, position=self.position,
style="green" if self.animation_running else "red", style=self.rich_style,
) )

View File

@@ -22,6 +22,7 @@ from rich.tree import Tree
from ._context import NoActiveAppError from ._context import NoActiveAppError
from ._node_list import NodeList from ._node_list import NodeList
from .binding import Bindings, BindingType
from .color import Color, WHITE, BLACK from .color import Color, WHITE, BLACK
from .css._error_tools import friendly_list from .css._error_tools import friendly_list
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
@@ -91,6 +92,9 @@ class DOMNode(MessagePump):
# Virtual DOM nodes # Virtual DOM nodes
COMPONENT_CLASSES: ClassVar[set[str]] = set() 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. # True if this node inherits the CSS from the base class.
_inherit_css: ClassVar[bool] = True _inherit_css: ClassVar[bool] = True
# List of names of base class (lower cased) that inherit CSS # 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: float | None = None
self._auto_refresh_timer: Timer | None = None self._auto_refresh_timer: Timer | None = None
self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)} self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)}
self._bindings = Bindings(self.BINDINGS)
super().__init__() super().__init__()
@@ -475,21 +480,25 @@ class DOMNode(MessagePump):
Returns: Returns:
Style: Rich Style object. Style: Rich Style object.
""" """
return Style.combine(
# TODO: Feels like there may be opportunity for caching here. node.styles.text_style for node in reversed(self.ancestors)
)
style = Style()
for node in reversed(self.ancestors):
style += node.styles.text_style
return style
@property @property
def rich_style(self) -> Style: def rich_style(self) -> Style:
"""Get a Rich Style object for this DOMNode.""" """Get a Rich Style object for this DOMNode."""
_, _, background, color = self.colors background = WHITE
style = ( color = BLACK
Style.from_color((background + color).rich_color, background.rich_color) style = Style()
+ self.text_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 return style
@@ -501,9 +510,7 @@ class DOMNode(MessagePump):
tuple[Color, Color]: Tuple of (base background, background) tuple[Color, Color]: Tuple of (base background, background)
""" """
base_background = background = BLACK base_background = background = BLACK
for node in reversed(self.ancestors): for node in reversed(self.ancestors):
styles = node.styles styles = node.styles
if styles.has_rule("background"): if styles.has_rule("background"):
@@ -533,15 +540,13 @@ class DOMNode(MessagePump):
@property @property
def ancestors(self) -> list[DOMNode]: def ancestors(self) -> list[DOMNode]:
"""Get a list of Nodes by tracing ancestors all the way back to App.""" """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 add_node = nodes.append
node: DOMNode = self node: MessagePump | None = self
while True: while node is not None:
node = node._parent
if node is None:
break
add_node(node) add_node(node)
return nodes node = node._parent
return cast(list[DOMNode], nodes)
@property @property
def displayed_children(self) -> list[Widget]: def displayed_children(self) -> list[Widget]:

View File

@@ -7,13 +7,8 @@ from operator import attrgetter
from typing import TYPE_CHECKING, ClassVar, Collection, Iterable, NamedTuple from typing import TYPE_CHECKING, ClassVar, Collection, Iterable, NamedTuple
import rich.repr import rich.repr
from rich.console import ( from rich.console import Console, ConsoleRenderable, JustifyMethod, RenderableType
Console, from rich.measure import Measurement
ConsoleRenderable,
Measurement,
JustifyMethod,
RenderableType,
)
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style from rich.style import Style
from rich.styled import Styled from rich.styled import Styled
@@ -27,6 +22,7 @@ from ._layout import Layout
from ._segment_tools import align_lines from ._segment_tools import align_lines
from ._styles_cache import StylesCache from ._styles_cache import StylesCache
from ._types import Lines from ._types import Lines
from .binding import NoBinding
from .box_model import BoxModel, get_box_model from .box_model import BoxModel, get_box_model
from .css.constants import VALID_TEXT_ALIGN from .css.constants import VALID_TEXT_ALIGN
from .dom import DOMNode, NoScreen from .dom import DOMNode, NoScreen
@@ -522,7 +518,7 @@ class Widget(DOMNode):
"""Get the height used by the *horizontal* scrollbar.""" """Get the height used by the *horizontal* scrollbar."""
styles = self.styles styles = self.styles
if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto": 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 return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0
@property @property
@@ -1474,7 +1470,12 @@ class Widget(DOMNode):
await self.broker_event("click", event) await self.broker_event("click", event)
async def _on_key(self, event: events.Key) -> None: 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: def _on_mount(self, event: events.Mount) -> None:
widgets = self.compose() widgets = self.compose()