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 {
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;
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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,
)

View File

@@ -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]:

View File

@@ -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()