355 lines
11 KiB
Python
355 lines
11 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from collections import defaultdict
|
||
|
|
from itertools import groupby
|
||
|
|
from typing import TYPE_CHECKING
|
||
|
|
|
||
|
|
import rich.repr
|
||
|
|
from rich.text import Text
|
||
|
|
|
||
|
|
from textual import events
|
||
|
|
from textual.app import ComposeResult
|
||
|
|
from textual.binding import Binding
|
||
|
|
from textual.containers import HorizontalGroup, ScrollableContainer
|
||
|
|
from textual.reactive import reactive
|
||
|
|
from textual.widget import Widget
|
||
|
|
from textual.widgets import Label
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from textual.screen import Screen
|
||
|
|
|
||
|
|
|
||
|
|
@rich.repr.auto
|
||
|
|
class KeyGroup(HorizontalGroup):
|
||
|
|
DEFAULT_CSS = """
|
||
|
|
KeyGroup {
|
||
|
|
width: auto;
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
@rich.repr.auto
|
||
|
|
class FooterKey(Widget):
|
||
|
|
ALLOW_SELECT = False
|
||
|
|
COMPONENT_CLASSES = {
|
||
|
|
"footer-key--key",
|
||
|
|
"footer-key--description",
|
||
|
|
}
|
||
|
|
|
||
|
|
DEFAULT_CSS = """
|
||
|
|
FooterKey {
|
||
|
|
width: auto;
|
||
|
|
height: 1;
|
||
|
|
text-wrap: nowrap;
|
||
|
|
background: $footer-item-background;
|
||
|
|
.footer-key--key {
|
||
|
|
color: $footer-key-foreground;
|
||
|
|
background: $footer-key-background;
|
||
|
|
text-style: bold;
|
||
|
|
padding: 0 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.footer-key--description {
|
||
|
|
padding: 0 1 0 0;
|
||
|
|
color: $footer-description-foreground;
|
||
|
|
background: $footer-description-background;
|
||
|
|
}
|
||
|
|
|
||
|
|
&:hover {
|
||
|
|
color: $footer-key-foreground;
|
||
|
|
background: $block-hover-background;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-disabled {
|
||
|
|
text-style: dim;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-compact {
|
||
|
|
.footer-key--key {
|
||
|
|
padding: 0;
|
||
|
|
}
|
||
|
|
.footer-key--description {
|
||
|
|
padding: 0 0 0 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
|
||
|
|
compact = reactive(True)
|
||
|
|
"""Display compact style."""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
key: str,
|
||
|
|
key_display: str,
|
||
|
|
description: str,
|
||
|
|
action: str,
|
||
|
|
disabled: bool = False,
|
||
|
|
tooltip: str = "",
|
||
|
|
classes="",
|
||
|
|
) -> None:
|
||
|
|
self.key = key
|
||
|
|
self.key_display = key_display
|
||
|
|
self.description = description
|
||
|
|
self.action = action
|
||
|
|
self._disabled = disabled
|
||
|
|
if disabled:
|
||
|
|
classes += " -disabled"
|
||
|
|
super().__init__(classes=classes)
|
||
|
|
self.shrink = False
|
||
|
|
if tooltip:
|
||
|
|
self.tooltip = tooltip
|
||
|
|
|
||
|
|
def render(self) -> Text:
|
||
|
|
key_style = self.get_component_rich_style("footer-key--key")
|
||
|
|
description_style = self.get_component_rich_style("footer-key--description")
|
||
|
|
key_display = self.key_display
|
||
|
|
key_padding = self.get_component_styles("footer-key--key").padding
|
||
|
|
description_padding = self.get_component_styles(
|
||
|
|
"footer-key--description"
|
||
|
|
).padding
|
||
|
|
|
||
|
|
description = self.description
|
||
|
|
if description:
|
||
|
|
label_text = Text.assemble(
|
||
|
|
(
|
||
|
|
" " * key_padding.left + key_display + " " * key_padding.right,
|
||
|
|
key_style,
|
||
|
|
),
|
||
|
|
(
|
||
|
|
" " * description_padding.left
|
||
|
|
+ description
|
||
|
|
+ " " * description_padding.right,
|
||
|
|
description_style,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
label_text = Text.assemble((key_display, key_style))
|
||
|
|
|
||
|
|
label_text.stylize_before(self.rich_style)
|
||
|
|
return label_text
|
||
|
|
|
||
|
|
def on_mouse_down(self) -> None:
|
||
|
|
if self._disabled:
|
||
|
|
self.app.bell()
|
||
|
|
else:
|
||
|
|
self.app.simulate_key(self.key)
|
||
|
|
|
||
|
|
def _watch_compact(self, compact: bool) -> None:
|
||
|
|
self.set_class(compact, "-compact")
|
||
|
|
|
||
|
|
|
||
|
|
class FooterLabel(Label):
|
||
|
|
"""Text displayed in the footer (used by binding groups)."""
|
||
|
|
|
||
|
|
|
||
|
|
@rich.repr.auto
|
||
|
|
class Footer(ScrollableContainer, can_focus=False, can_focus_children=False):
|
||
|
|
ALLOW_SELECT = False
|
||
|
|
DEFAULT_CSS = """
|
||
|
|
Footer {
|
||
|
|
layout: horizontal;
|
||
|
|
color: $footer-foreground;
|
||
|
|
background: $footer-background;
|
||
|
|
dock: bottom;
|
||
|
|
height: 1;
|
||
|
|
scrollbar-size: 0 0;
|
||
|
|
&.-compact {
|
||
|
|
FooterLabel {
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
FooterKey {
|
||
|
|
margin-right: 1;
|
||
|
|
}
|
||
|
|
FooterKey.-grouped {
|
||
|
|
margin: 0 1;
|
||
|
|
}
|
||
|
|
FooterKey.-command-palette {
|
||
|
|
padding-right: 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
FooterKey.-command-palette {
|
||
|
|
dock: right;
|
||
|
|
padding-right: 1;
|
||
|
|
border-left: vkey $foreground 20%;
|
||
|
|
}
|
||
|
|
HorizontalGroup.binding-group {
|
||
|
|
width: auto;
|
||
|
|
height: 1;
|
||
|
|
layout: horizontal;
|
||
|
|
}
|
||
|
|
KeyGroup.-compact {
|
||
|
|
FooterKey.-grouped {
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
margin: 0 1 0 0;
|
||
|
|
padding-left: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
FooterKey.-grouped {
|
||
|
|
margin: 0 1;
|
||
|
|
}
|
||
|
|
FooterLabel {
|
||
|
|
margin: 0 1 0 0;
|
||
|
|
color: $footer-description-foreground;
|
||
|
|
background: $footer-description-background;
|
||
|
|
}
|
||
|
|
|
||
|
|
&:ansi {
|
||
|
|
background: ansi_default;
|
||
|
|
.footer-key--key {
|
||
|
|
background: ansi_default;
|
||
|
|
color: ansi_magenta;
|
||
|
|
}
|
||
|
|
.footer-key--description {
|
||
|
|
background: ansi_default;
|
||
|
|
color: ansi_default;
|
||
|
|
}
|
||
|
|
FooterKey:hover {
|
||
|
|
text-style: underline;
|
||
|
|
background: ansi_default;
|
||
|
|
color: ansi_default;
|
||
|
|
.footer-key--key {
|
||
|
|
background: ansi_default;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
FooterKey.-command-palette {
|
||
|
|
background: ansi_default;
|
||
|
|
border-left: vkey ansi_black;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
|
||
|
|
compact = reactive(False, toggle_class="-compact")
|
||
|
|
"""Display in compact style."""
|
||
|
|
_bindings_ready = reactive(False, repaint=False)
|
||
|
|
"""True if the bindings are ready to be displayed."""
|
||
|
|
show_command_palette = reactive(True)
|
||
|
|
"""Show the key to invoke the command palette."""
|
||
|
|
combine_groups = reactive(True)
|
||
|
|
"""Combine bindings in the same group?"""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
*children: Widget,
|
||
|
|
name: str | None = None,
|
||
|
|
id: str | None = None,
|
||
|
|
classes: str | None = None,
|
||
|
|
disabled: bool = False,
|
||
|
|
show_command_palette: bool = True,
|
||
|
|
compact: bool = False,
|
||
|
|
) -> None:
|
||
|
|
"""A footer to show key bindings.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
*children: Child widgets.
|
||
|
|
name: The name of the widget.
|
||
|
|
id: The ID of the widget in the DOM.
|
||
|
|
classes: The CSS classes for the widget.
|
||
|
|
disabled: Whether the widget is disabled or not.
|
||
|
|
show_command_palette: Show key binding to invoke the command palette, on the right of the footer.
|
||
|
|
compact: Display a compact style (less whitespace) footer.
|
||
|
|
"""
|
||
|
|
super().__init__(
|
||
|
|
*children,
|
||
|
|
name=name,
|
||
|
|
id=id,
|
||
|
|
classes=classes,
|
||
|
|
disabled=disabled,
|
||
|
|
)
|
||
|
|
self.set_reactive(Footer.show_command_palette, show_command_palette)
|
||
|
|
self.compact = compact
|
||
|
|
|
||
|
|
def compose(self) -> ComposeResult:
|
||
|
|
if not self._bindings_ready:
|
||
|
|
return
|
||
|
|
active_bindings = self.screen.active_bindings
|
||
|
|
bindings = [
|
||
|
|
(binding, enabled, tooltip)
|
||
|
|
for (_, binding, enabled, tooltip) in active_bindings.values()
|
||
|
|
if binding.show
|
||
|
|
]
|
||
|
|
action_to_bindings: defaultdict[str, list[tuple[Binding, bool, str]]]
|
||
|
|
action_to_bindings = defaultdict(list)
|
||
|
|
for binding, enabled, tooltip in bindings:
|
||
|
|
action_to_bindings[binding.action].append((binding, enabled, tooltip))
|
||
|
|
|
||
|
|
self.styles.grid_size_columns = len(action_to_bindings)
|
||
|
|
|
||
|
|
for group, multi_bindings_iterable in groupby(
|
||
|
|
action_to_bindings.values(),
|
||
|
|
lambda multi_bindings_: multi_bindings_[0][0].group,
|
||
|
|
):
|
||
|
|
multi_bindings = list(multi_bindings_iterable)
|
||
|
|
if group is not None and len(multi_bindings) > 1:
|
||
|
|
with KeyGroup(classes="-compact" if group.compact else ""):
|
||
|
|
for multi_bindings in multi_bindings:
|
||
|
|
binding, enabled, tooltip = multi_bindings[0]
|
||
|
|
yield FooterKey(
|
||
|
|
binding.key,
|
||
|
|
self.app.get_key_display(binding),
|
||
|
|
"",
|
||
|
|
binding.action,
|
||
|
|
disabled=not enabled,
|
||
|
|
tooltip=tooltip or binding.description,
|
||
|
|
classes="-grouped",
|
||
|
|
).data_bind(compact=Footer.compact)
|
||
|
|
yield FooterLabel(group.description)
|
||
|
|
else:
|
||
|
|
for multi_bindings in multi_bindings:
|
||
|
|
binding, enabled, tooltip = multi_bindings[0]
|
||
|
|
yield FooterKey(
|
||
|
|
binding.key,
|
||
|
|
self.app.get_key_display(binding),
|
||
|
|
binding.description,
|
||
|
|
binding.action,
|
||
|
|
disabled=not enabled,
|
||
|
|
tooltip=tooltip,
|
||
|
|
).data_bind(compact=Footer.compact)
|
||
|
|
if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE:
|
||
|
|
try:
|
||
|
|
_node, binding, enabled, tooltip = active_bindings[
|
||
|
|
self.app.COMMAND_PALETTE_BINDING
|
||
|
|
]
|
||
|
|
except KeyError:
|
||
|
|
pass
|
||
|
|
else:
|
||
|
|
yield FooterKey(
|
||
|
|
binding.key,
|
||
|
|
self.app.get_key_display(binding),
|
||
|
|
binding.description,
|
||
|
|
binding.action,
|
||
|
|
classes="-command-palette",
|
||
|
|
disabled=not enabled,
|
||
|
|
tooltip=binding.tooltip or binding.description,
|
||
|
|
)
|
||
|
|
|
||
|
|
def bindings_changed(self, screen: Screen) -> None:
|
||
|
|
self._bindings_ready = True
|
||
|
|
if not screen.app.app_focus:
|
||
|
|
return
|
||
|
|
if self.is_attached and screen is self.screen:
|
||
|
|
self.call_after_refresh(self.recompose)
|
||
|
|
|
||
|
|
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
|
||
|
|
if self.allow_horizontal_scroll:
|
||
|
|
self.release_anchor()
|
||
|
|
if self._scroll_right_for_pointer(animate=True):
|
||
|
|
event.stop()
|
||
|
|
event.prevent_default()
|
||
|
|
|
||
|
|
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
||
|
|
if self.allow_horizontal_scroll:
|
||
|
|
self.release_anchor()
|
||
|
|
if self._scroll_left_for_pointer(animate=True):
|
||
|
|
event.stop()
|
||
|
|
event.prevent_default()
|
||
|
|
|
||
|
|
def on_mount(self) -> None:
|
||
|
|
self.screen.bindings_updated_signal.subscribe(self, self.bindings_changed)
|
||
|
|
|
||
|
|
def on_unmount(self) -> None:
|
||
|
|
self.screen.bindings_updated_signal.unsubscribe(self)
|