mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
focus level bindings
This commit is contained in:
@@ -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%;
|
||||
max-width: 50%;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user