diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ffcc3ba3..e7448d1f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `tooltip` to Binding https://github.com/Textualize/textual/pull/4859 - Added a link to the command palette to the Footer (set `show_command_palette=False` to disable) https://github.com/Textualize/textual/pull/4867 - Added `TOOLTIP_DELAY` to App to customize time until a tooltip is displayed +- Added "Show keys" option to system commands to show a summary of key bindings. https://github.com/Textualize/textual/pull/4876 +- Added "split" CSS style, currently undocumented, and may change. https://github.com/Textualize/textual/pull/4876 +- Added `Region.get_spacing_between` https://github.com/Textualize/textual/pull/4876 ### Changed - Removed caps_lock and num_lock modifiers https://github.com/Textualize/textual/pull/4861 +- Keys such as escape and space are now displayed in lower case in footer https://github.com/Textualize/textual/pull/4876 ### Fixed diff --git a/examples/markdown.py b/examples/markdown.py index 2cf43e4399..eb8bd16391 100644 --- a/examples/markdown.py +++ b/examples/markdown.py @@ -4,6 +4,7 @@ from sys import argv from textual.app import App, ComposeResult +from textual.binding import Binding from textual.reactive import var from textual.widgets import Footer, MarkdownViewer @@ -12,9 +13,14 @@ class MarkdownApp(App): """A simple Markdown viewer application.""" BINDINGS = [ - ("t", "toggle_table_of_contents", "TOC"), - ("b", "back", "Back"), - ("f", "forward", "Forward"), + Binding( + "t", + "toggle_table_of_contents", + "TOC", + tooltip="Toggle the Table of Contents Panel", + ), + Binding("b", "back", "Back", tooltip="Navigate back"), + Binding("f", "forward", "Forward", tooltip="Navigate forward"), ] path = var(Path(__file__).parent / "demo.md") diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 97d8abbe4a..52b1ea1c85 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -48,6 +48,7 @@ def arrange( placements: list[WidgetPlacement] = [] scroll_spacing = Spacing() get_dock = attrgetter("styles.dock") + get_split = attrgetter("styles.split") styles = widget.styles # Widgets which will be displayed @@ -56,39 +57,49 @@ def arrange( # Widgets organized into layers dock_layers = _build_dock_layers(display_widgets) - layer_region = size.region for widgets in dock_layers.values(): - region = layer_region + # Partition widgets in to split widgets and non-split widgets + non_split_widgets, split_widgets = partition(get_split, widgets) + if split_widgets: + _split_placements, dock_region = _arrange_split_widgets( + split_widgets, size, viewport + ) + placements.extend(_split_placements) + else: + dock_region = size.region + + split_spacing = size.region.get_spacing_between(dock_region) # Partition widgets into "layout" widgets (those that appears in the normal 'flow' of the # document), and "dock" widgets which are positioned relative to an edge - layout_widgets, dock_widgets = partition(get_dock, widgets) + layout_widgets, dock_widgets = partition(get_dock, non_split_widgets) # Arrange docked widgets - _dock_placements, dock_spacing = _arrange_dock_widgets( - dock_widgets, size, viewport - ) - placements.extend(_dock_placements) + if dock_widgets: + _dock_placements, dock_spacing = _arrange_dock_widgets( + dock_widgets, dock_region, viewport + ) + placements.extend(_dock_placements) + dock_region = dock_region.shrink(dock_spacing) + else: + dock_spacing = Spacing() - # Reduce the region to compensate for docked widgets - region = region.shrink(dock_spacing) + dock_spacing += split_spacing if layout_widgets: # Arrange layout widgets (i.e. not docked) layout_placements = widget._layout.arrange( widget, layout_widgets, - region.size, + dock_region.size, ) - scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) - - placement_offset = region.offset + placement_offset = dock_region.offset # Perform any alignment of the widgets. if styles.align_horizontal != "left" or styles.align_vertical != "top": bounding_region = WidgetPlacement.get_bounds(layout_placements) placement_offset += styles._align_size( - bounding_region.size, region.size + bounding_region.size, dock_region.size ).clamped if placement_offset: @@ -103,20 +114,22 @@ def arrange( def _arrange_dock_widgets( - dock_widgets: Sequence[Widget], size: Size, viewport: Size + dock_widgets: Sequence[Widget], region: Region, viewport: Size ) -> tuple[list[WidgetPlacement], Spacing]: """Arrange widgets which are *docked*. Args: dock_widgets: Widgets with a non-empty dock. - size: Size of the container. + region: Region to dock within. viewport: Size of the viewport. Returns: - A tuple of widget placements, and additional spacing around them + A tuple of widget placements, and additional spacing around them. """ _WidgetPlacement = WidgetPlacement top_z = TOP_Z + region_offset = region.offset + size = region.size width, height = size null_spacing = Spacing() @@ -132,7 +145,6 @@ def _arrange_dock_widgets( size, viewport, Fraction(size.width), Fraction(size.height) ) widget_width_fraction, widget_height_fraction, margin = box_model - widget_width = int(widget_width_fraction) + margin.width widget_height = int(widget_height_fraction) + margin.height @@ -157,7 +169,59 @@ def _arrange_dock_widgets( ) dock_region = dock_region.shrink(margin).translate(align_offset) append_placement( - _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) + _WidgetPlacement( + dock_region.translate(region_offset), + null_spacing, + dock_widget, + top_z, + True, + ) ) dock_spacing = Spacing(top, right, bottom, left) return (placements, dock_spacing) + + +def _arrange_split_widgets( + split_widgets: Sequence[Widget], size: Size, viewport: Size +) -> tuple[list[WidgetPlacement], Region]: + """Arrange split widgets. + + Split widgets are "docked" but also reduce the area available for regular widgets. + + Args: + split_widgets: Widgets to arrange. + size: Available area to arrange. + viewport: Viewport (size of terminal). + + Returns: + A tuple of widget placements, and the remaining view area. + """ + _WidgetPlacement = WidgetPlacement + placements: list[WidgetPlacement] = [] + append_placement = placements.append + view_region = size.region + null_spacing = Spacing() + + for split_widget in split_widgets: + split = split_widget.styles.split + box_model = split_widget._get_box_model( + size, viewport, Fraction(size.width), Fraction(size.height) + ) + widget_width_fraction, widget_height_fraction, margin = box_model + if split == "bottom": + widget_height = int(widget_height_fraction) + margin.height + view_region, split_region = view_region.split_horizontal(-widget_height) + elif split == "top": + widget_height = int(widget_height_fraction) + margin.height + split_region, view_region = view_region.split_horizontal(widget_height) + elif split == "left": + widget_width = int(widget_width_fraction) + margin.width + split_region, view_region = view_region.split_vertical(widget_width) + elif split == "right": + widget_width = int(widget_width_fraction) + margin.width + view_region, split_region = view_region.split_vertical(-widget_width) + append_placement( + _WidgetPlacement(split_region, null_spacing, split_widget, 1, True) + ) + + return placements, view_region diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 312f8c9fe5..a387603634 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -160,8 +160,10 @@ def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> if not widget._nodes: width = 0 else: - arrangement = widget._arrange(Size(0, 0)) - return arrangement.total_region.right + arrangement = widget._arrange( + Size(0 if widget.shrink else container.width, 0) + ) + width = arrangement.total_region.right return width def get_content_height( diff --git a/src/textual/app.py b/src/textual/app.py index 6bb0fbe827..aa8595bb59 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1319,7 +1319,9 @@ def bind( keys, action, description, show=show, key_display=key_display ) - def get_key_display(self, key: str) -> str: + def get_key_display( + self, key: str, upper_case_keys: bool = False, ctrl_to_caret: bool = True + ) -> str: """For a given key, return how it should be displayed in an app (e.g. in the Footer widget). By key, we refer to the string used in the "key" argument for @@ -1329,11 +1331,15 @@ def get_key_display(self, key: str) -> str: Args: key: The binding key string. + upper_case_keys: Upper case printable keys. + ctrl_to_caret: Replace `ctrl+` with `^`. Returns: The display string for the input key. """ - return _get_key_display(key) + return _get_key_display( + key, upper_case_keys=upper_case_keys, ctrl_to_caret=ctrl_to_caret + ) async def _press_keys(self, keys: Iterable[str]) -> None: """A task to send key events.""" @@ -3498,6 +3504,19 @@ def action_focus_previous(self) -> None: """An [action](/guide/actions) to focus the previous widget.""" self.screen.focus_previous() + def action_hide_keys(self) -> None: + """Hide the keys panel (if present).""" + self.screen.query("KeyPanel").remove() + + def action_show_keys(self) -> None: + """Show the keys panel.""" + from .widgets import KeyPanel + + try: + self.query_one(KeyPanel) + except NoMatches: + self.mount(KeyPanel()) + def _on_terminal_supports_synchronized_output( self, message: messages.TerminalSupportsSynchronizedOutput ) -> None: diff --git a/src/textual/command.py b/src/textual/command.py index 44a03ca72c..2228a1d589 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -436,7 +436,6 @@ class CommandPalette(SystemModalScreen): """ DEFAULT_CSS = """ - CommandPalette:inline { /* If the command palette is invoked in inline mode, we may need additional lines. */ @@ -627,7 +626,7 @@ def compose(self) -> ComposeResult: Returns: The content of the screen. """ - with Vertical(): + with Vertical(id="--container"): with Horizontal(id="--input"): yield SearchIcon() yield CommandInput(placeholder="Search for commands…") @@ -1068,10 +1067,15 @@ def _select_or_command( if event is not None: event.stop() if self._list_visible: + command_list = self.query_one(CommandList) # ...so if nothing in the list is highlighted yet... - if self.query_one(CommandList).highlighted is None: + if command_list.highlighted is None: # ...cause the first completion to be highlighted. self._action_cursor_down() + # If there is one option, assume the user wants to select it + if command_list.option_count == 1: + # Call after a short delay to provide a little visual feedback + self._action_command_list("select") else: # The list is visible, something is highlighted, the user # made a selection "gesture"; let's go select it! @@ -1095,15 +1099,9 @@ def _stop_event_leak(self, event: OptionList.OptionHighlighted) -> None: def _action_escape(self) -> None: """Handle a request to escape out of the command palette.""" - input = self.query_one(CommandInput) - # Hide the options if there are result and there is input - if self._list_visible and (self._hit_count and input.value): - self._list_visible = False - # Otherwise dismiss modal - else: - self._cancel_gather_commands() - self.app.post_message(CommandPalette.Closed(option_selected=False)) - self.dismiss() + self._cancel_gather_commands() + self.app.post_message(CommandPalette.Closed(option_selected=False)) + self.dismiss() def _action_command_list(self, action: str) -> None: """Pass an action on to the [`CommandList`][textual.command.CommandList]. diff --git a/src/textual/constants.py b/src/textual/constants.py index 3dbbc17988..8cde416796 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -113,8 +113,8 @@ def _get_textual_animations() -> AnimationLevel: COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto") """Force color system override.""" -TEXTUAL_ANIMATIONS: AnimationLevel = _get_textual_animations() +TEXTUAL_ANIMATIONS: Final[AnimationLevel] = _get_textual_animations() """Determines whether animations run or not.""" -ESCAPE_DELAY: float = _get_environ_int("ESCDELAY", 100) / 1000.0 +ESCAPE_DELAY: Final[float] = _get_environ_int("ESCDELAY", 100) / 1000.0 """The delay (in seconds) before reporting an escape key (not used if the extend key protocol is available).""" diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index f2ca1564c8..da811bad73 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -476,6 +476,39 @@ def dock_property_help_text(property_name: str, context: StylingContext) -> Help ) +def split_property_help_text(property_name: str, context: StylingContext) -> HelpText: + """Help text to show when the user supplies an invalid value for split. + + Args: + property_name: The name of the property. + context: The context the property is being used in. + + Returns: + Renderable for displaying the help text for this property. + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value for [i]{property_name}[/] property", + bullets=[ + Bullet("The value must be one of 'top', 'right', 'bottom' or 'left'"), + *ContextSpecificBullets( + inline=[ + Bullet( + "The 'split' splits the container and aligns the widget to the given edge.", + examples=[Example('header.styles.split = "top"')], + ) + ], + css=[ + Bullet( + "The 'split' splits the container and aligns the widget to the given edge.", + examples=[Example("split: top")], + ) + ], + ).get_by_context(context), + ], + ) + + def fractional_property_help_text( property_name: str, context: StylingContext ) -> HelpText: diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index f35dd74de1..feaaf07add 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -599,6 +599,37 @@ def __set__(self, obj: StylesBase, dock_name: str | None): obj.refresh(layout=True) +class SplitProperty: + """Descriptor for getting and setting the split property. The split property + allows you to specify which edge you want to split. + """ + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> DockEdge: + """Get the Dock property. + + Args: + obj: The ``Styles`` object. + objtype: The ``Styles`` class. + + Returns: + The dock name as a string, or "" if the rule is not set. + """ + return obj.get_rule("split", "") # type: ignore[return-value] + + def __set__(self, obj: StylesBase, dock_name: str | None): + """Set the Dock property. + + Args: + obj: The ``Styles`` object. + dock_name: The name of the dock to attach this widget to. + """ + _rich_traceback_omit = True + if obj.set_rule("split", dock_name): + obj.refresh(layout=True) + + class LayoutProperty: """Descriptor for getting and setting layout.""" diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 622312a861..3107375a83 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -31,6 +31,7 @@ scrollbar_size_single_axis_help_text, spacing_invalid_value_help_text, spacing_wrong_number_of_values_help_text, + split_property_help_text, string_enum_help_text, style_flags_property_help_text, table_rows_or_columns_help_text, @@ -748,6 +749,20 @@ def process_dock(self, name: str, tokens: list[Token]) -> None: dock = tokens[0].value self.styles._rules["dock"] = dock + def process_split(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + + if len(tokens) > 1 or tokens[0].value not in VALID_EDGE: + self.error( + name, + tokens[0], + split_property_help_text(name, context="css"), + ) + + dock = tokens[0].value + self.styles._rules["split"] = dock + def process_layer(self, name: str, tokens: list[Token]) -> None: if len(tokens) > 1: self.error(name, tokens[1], "unexpected tokens in dock-edge declaration") diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 2364977746..c2d14ea02b 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from functools import lru_cache, partial from operator import attrgetter -from typing import TYPE_CHECKING, Any, Callable, Iterable, NamedTuple, cast +from typing import TYPE_CHECKING, Any, Callable, Iterable, cast import rich.repr from rich.style import Style @@ -33,6 +33,7 @@ ScalarProperty, ScrollbarColorProperty, SpacingProperty, + SplitProperty, StringEnumProperty, StyleFlagsProperty, TransitionsProperty, @@ -58,7 +59,6 @@ BoxSizing, Constrain, Display, - Edge, Overflow, Overlay, ScrollbarGutter, @@ -122,6 +122,7 @@ class RulesMap(TypedDict, total=False): max_height: Scalar dock: str + split: str overflow_x: Overflow overflow_y: Overflow @@ -197,12 +198,6 @@ class RulesMap(TypedDict, total=False): _rule_getter = attrgetter(*RULE_NAMES) -class DockGroup(NamedTuple): - name: str - edge: Edge - z: int - - class StylesBase: """A common base class for Styles and RenderStyles""" @@ -282,6 +277,7 @@ class StylesBase: max_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False) dock = DockProperty() + split = SplitProperty() overflow_x = OverflowProperty(VALID_OVERFLOW, "hidden") overflow_y = OverflowProperty(VALID_OVERFLOW, "hidden") @@ -894,6 +890,8 @@ def append_declaration(name: str, value: str) -> None: append_declaration("offset", f"{x} {y}") if "dock" in rules: append_declaration("dock", rules["dock"]) + if "split" in rules: + append_declaration("split", rules["split"]) if "layers" in rules: append_declaration("layers", " ".join(self.layers)) if "layer" in rules: diff --git a/src/textual/dom.py b/src/textual/dom.py index 510f43780f..e5dceebe79 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -206,9 +206,7 @@ def __init__( self._reactive_connect: ( dict[str, tuple[MessagePump, Reactive | object]] | None ) = None - self._pruning = False - super().__init__() def set_reactive( diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index ba1cf8c25c..16e2604f85 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -247,9 +247,7 @@ def on_terminal_resize(signum, stack) -> None: self.write("\x1b[?1004h") # Enable FocusIn/FocusOut. self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ # Disambiguate escape codes https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement - # self.write( - # "\x1b[1;u" - # ) # Causes https://github.com/Textualize/textual/issues/4870 on iTerm + self.write("\x1b[=1;u") self.flush() self._key_thread = Thread(target=self._run_input_thread) send_size_event() diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 2bf2004acf..96ac2f5be6 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -583,6 +583,22 @@ def __sub__(self, other: object) -> Region: return Region(x - ox, y - oy, width, height) return NotImplemented + def get_spacing_between(self, region: Region) -> Spacing: + """Get spacing between two regions. + + Args: + region: Another region. + + Returns: + Spacing that if subtracted from `self` produces `region`. + """ + return Spacing( + region.y - self.y, + self.right - region.right, + self.bottom - region.bottom, + region.x - self.x, + ) + def at_offset(self, offset: tuple[int, int]) -> Region: """Get a new Region with the same size at a given offset. diff --git a/src/textual/keys.py b/src/textual/keys.py index 0006b9a645..72e4cdc395 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -259,10 +259,12 @@ def value(self) -> str: "left": "←", "right": "→", "backspace": "⌫", - "escape": "ESC", + "escape": "esc", "enter": "⏎", "minus": "-", - "space": "SPACE", + "space": "space", + "pagedown": "pgdn", + "pageup": "pgup", } @@ -279,12 +281,24 @@ def _get_key_aliases(key: str) -> list[str]: return [key] + KEY_ALIASES.get(key, []) -def _get_key_display(key: str) -> str: +def _get_key_display( + key: str, + upper_case_keys: bool = False, + ctrl_to_caret: bool = True, +) -> str: """Given a key (i.e. the `key` string argument to Binding __init__), return the value that should be displayed in the app when referring to this key (e.g. in the Footer widget).""" if "+" in key: - return "+".join([_get_key_display(key) for key in key.split("+")]) + key_components = key.split("+") + caret = False + if ctrl_to_caret and "ctrl" in key_components: + key_components.remove("ctrl") + caret = True + key_display = ("^" if caret else "") + "+".join( + [_get_key_display(key) for key in key_components] + ) + return key_display display_alias = KEY_DISPLAY_ALIASES.get(key) if display_alias: @@ -300,7 +314,7 @@ def _get_key_display(key: str) -> str: # Check if printable. `delete` for example maps to a control sequence # which we don't want to write to the terminal. if unicode_character.isprintable(): - return unicode_character + return unicode_character.upper() if upper_case_keys else unicode_character return tentative_unicode_name diff --git a/src/textual/render.py b/src/textual/render.py index c1003b062f..c12bbbd453 100644 --- a/src/textual/render.py +++ b/src/textual/render.py @@ -30,13 +30,12 @@ def measure( renderable = rich_cast(renderable) get_console_width = getattr(renderable, "__rich_measure__", None) if get_console_width is not None: - render_width = get_console_width( - console, - ( - console.options - if container_width is None - else console.options.update_width(container_width) - ), - ).maximum + options = ( + console.options + if container_width is None + else console.options.update_width(container_width) + ) + render_width = get_console_width(console, options).maximum width = max(0, render_width) + return width diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py index 69cc17c953..705303c29e 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -13,6 +13,8 @@ from __future__ import annotations +from typing import Iterable + from .command import DiscoveryHit, Hit, Hits, Provider from .types import IgnoreReturnCallbackType @@ -24,33 +26,44 @@ class SystemCommands(Provider): """ @property - def _system_commands(self) -> tuple[tuple[str, IgnoreReturnCallbackType, str], ...]: + def _system_commands(self) -> Iterable[tuple[str, IgnoreReturnCallbackType, str]]: """The system commands to reveal to the command palette.""" - return ( - ( - "Toggle light/dark mode", + if self.app.dark: + yield ( + "Light mode", + self.app.action_toggle_dark, + "Switch to a light background", + ) + else: + yield ( + "Dark mode", self.app.action_toggle_dark, - "Toggle the application between light and dark mode", - ), - ( - "Quit the application", - self.app.action_quit, - "Quit the application as soon as possible", - ), - ( - "Ring the bell", - self.app.action_bell, - "Ring the terminal's 'bell'", - ), + "Switch to a dark background", + ) + + yield ( + "Quit the application", + self.app.action_quit, + "Quit the application as soon as possible", ) + if self.screen.query("KeyPanel"): + yield ("Hide keys", self.app.action_hide_keys, "Hide the keys panel") + else: + yield ( + "Show keys", + self.app.action_show_keys, + "Show a summary of available keys", + ) + async def discover(self) -> Hits: """Handle a request for the discovery commands for this provider. Yields: Commands that can be discovered. """ - for name, runnable, help_text in self._system_commands: + commands = sorted(self._system_commands, key=lambda command: command[0]) + for name, runnable, help_text in commands: yield DiscoveryHit( name, runnable, diff --git a/src/textual/widget.py b/src/textual/widget.py index f3f8196aed..0668e3bbc8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1233,6 +1233,7 @@ def _get_box_model( content_width = Fraction(content_container.width - margin.width) elif is_auto_width: # When width is auto, we want enough space to always fit the content + content_width = Fraction( self.get_content_width(content_container - margin.totals, viewport) ) @@ -1348,7 +1349,6 @@ def get_content_width(self, container: Size, viewport: Size) -> int: """ if self.is_container: - assert self._layout is not None width = self._layout.get_content_width(self, container, viewport) return width @@ -1368,6 +1368,7 @@ def get_content_width(self, container: Size, viewport: Size) -> int: width = min(width, container.width) self._content_width_cache = (cache_key, width) + return width def get_content_height(self, container: Size, viewport: Size, width: int) -> int: @@ -1997,7 +1998,6 @@ def layers(self) -> tuple[str, ...]: break if node.styles.has_rule("layers"): layers = node.styles.layers - return layers @property diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index a9505aa0dc..bff80be2ce 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -21,6 +21,7 @@ from ._footer import Footer from ._header import Header from ._input import Input + from ._key_panel import KeyPanel from ._label import Label from ._list_item import ListItem from ._list_view import ListView @@ -59,6 +60,7 @@ "Footer", "Header", "Input", + "KeyPanel", "Label", "ListItem", "ListView", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 93b3af4d66..f09f042d97 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -10,6 +10,7 @@ from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer from ._header import Header as Header from ._input import Input as Input +from ._key_panel import KeyPanel as KeyPanel from ._label import Label as Label from ._list_item import ListItem as ListItem from ._list_view import ListView as ListView diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index c26ec2cd30..3db09a2938 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -151,8 +151,7 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): grid-gutter: 1; } FooterKey.-command-palette { - dock: right; - + dock: right; padding-right: 1; border-left: vkey $foreground 20%; } @@ -251,6 +250,8 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: async def bindings_changed(screen: Screen) -> None: self._bindings_ready = True + if not screen.app.app_focus: + return if self.is_attached and screen is self.screen: await self.recompose() diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py new file mode 100644 index 0000000000..7f4441322c --- /dev/null +++ b/src/textual/widgets/_key_panel.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +from rich.table import Table +from rich.text import Text + +from ..app import ComposeResult +from ..binding import Binding +from ..containers import VerticalScroll +from ..reactive import reactive +from ..widgets import Static + +if TYPE_CHECKING: + from ..screen import Screen + + +class BindingsTable(Static): + """A widget to display bindings.""" + + COMPONENT_CLASSES = {"bindings-table--key", "bindings-table--description"} + + DEFAULT_CSS = """ + BindingsTable { + width: auto; + height: auto; + } + """ + + upper_case_keys = reactive(False) + """Upper case key display.""" + ctrl_to_caret = reactive(True) + """Convert 'ctrl+' prefix to '^'.""" + + def render_bindings_table(self) -> Table: + """Render a table with all the key bindings. + + Returns: + A Rich Table. + """ + bindings = [ + (binding, enabled, tooltip) + for (_, binding, enabled, tooltip) in self.screen.active_bindings.values() + ] + 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)) + + table = Table.grid(padding=(0, 1)) + key_style = self.get_component_rich_style("bindings-table--key") + description_style = self.get_component_rich_style("bindings-table--description") + + def render_description(binding: Binding) -> Text: + """Render description text from a binding.""" + text = Text.from_markup( + binding.description, end="", style=description_style + ) + if binding.tooltip: + text.append(" ") + text.append(binding.tooltip, "dim") + return text + + table.add_column("", justify="right") + for multi_bindings in action_to_bindings.values(): + binding, enabled, tooltip = multi_bindings[0] + table.add_row( + Text( + binding.key_display + or self.app.get_key_display( + binding.key, + upper_case_keys=self.upper_case_keys, + ctrl_to_caret=self.ctrl_to_caret, + ), + style=key_style, + ), + render_description(binding), + ) + + return table + + def render(self) -> Table: + return self.render_bindings_table() + + +class KeyPanel(VerticalScroll, can_focus=False): + DEFAULT_CSS = """ + KeyPanel { + split: right; + width: 33%; + min-width: 30; + max-width: 60; + border-left: vkey $foreground 30%; + padding: 1 1; + height: 1fr; + padding-right: 1; + + &> BindingsTable > .bindings-table--key { + color: $secondary; + text-style: bold; + padding: 0 1; + } + + &> BindingsTable > .bindings-table--description { + color: $text; + } + + #bindings-table { + width: auto; + height: auto; + } + } + """ + + upper_case_keys = reactive(False) + """Upper case key display.""" + ctrl_to_caret = reactive(True) + """Convert 'ctrl+' prefix to '^'.""" + + DEFAULT_CLASSES = "-textual-system" + + def compose(self) -> ComposeResult: + yield BindingsTable(shrink=True, expand=False).data_bind( + KeyPanel.upper_case_keys, + KeyPanel.ctrl_to_caret, + ) + + async def on_mount(self) -> None: + async def bindings_changed(screen: Screen) -> None: + if not screen.app.app_focus: + return + if self.is_attached and screen is self.screen: + self.refresh(recompose=True) + + self.screen.bindings_updated_signal.subscribe(self, bindings_changed) + + def on_unmount(self) -> None: + self.screen.bindings_updated_signal.unsubscribe(self) diff --git a/tests/command_palette/test_escaping.py b/tests/command_palette/test_escaping.py index 83bf36ad15..11fb1ded87 100644 --- a/tests/command_palette/test_escaping.py +++ b/tests/command_palette/test_escaping.py @@ -23,28 +23,3 @@ async def test_escape_closes_when_no_list_visible() -> None: assert CommandPalette.is_open(pilot.app) await pilot.press("escape") assert not CommandPalette.is_open(pilot.app) - - -async def test_escape_does_not_close_when_list_visible() -> None: - """Pressing escape when a hit list is visible should not close the command palette.""" - async with CommandPaletteApp().run_test() as pilot: - assert CommandPalette.is_open(pilot.app) - await pilot.press("a") - await pilot.press("escape") - assert CommandPalette.is_open(pilot.app) - await pilot.press("escape") - assert not CommandPalette.is_open(pilot.app) - - -async def test_down_arrow_should_undo_closing_of_list_via_escape() -> None: - """Down arrow should reopen the hit list if escape closed it before.""" - async with CommandPaletteApp().run_test() as pilot: - assert CommandPalette.is_open(pilot.app) - await pilot.press("a") - await pilot.press("escape") - assert CommandPalette.is_open(pilot.app) - await pilot.press("down") - await pilot.press("escape") - assert CommandPalette.is_open(pilot.app) - await pilot.press("escape") - assert not CommandPalette.is_open(pilot.app) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_auto_tab_active.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_auto_tab_active.svg index 7d9500e809..0d1ef0520c 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_auto_tab_active.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_auto_tab_active.svg @@ -19,144 +19,144 @@ font-weight: 700; } - .terminal-1993010201-matrix { + .terminal-304540857-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1993010201-title { + .terminal-304540857-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1993010201-r1 { fill: #c5c8c6 } -.terminal-1993010201-r2 { fill: #e1e1e1 } -.terminal-1993010201-r3 { fill: #f4005f } -.terminal-1993010201-r4 { fill: #98e024;font-weight: bold } -.terminal-1993010201-r5 { fill: #323232 } -.terminal-1993010201-r6 { fill: #0178d4 } -.terminal-1993010201-r7 { fill: #98e024 } -.terminal-1993010201-r8 { fill: #7ae998 } -.terminal-1993010201-r9 { fill: #4ebf71;font-weight: bold } -.terminal-1993010201-r10 { fill: #008139 } -.terminal-1993010201-r11 { fill: #fea62b;font-weight: bold } -.terminal-1993010201-r12 { fill: #a7a9ab } -.terminal-1993010201-r13 { fill: #e2e3e3 } -.terminal-1993010201-r14 { fill: #4c5055 } + .terminal-304540857-r1 { fill: #c5c8c6 } +.terminal-304540857-r2 { fill: #e1e1e1 } +.terminal-304540857-r3 { fill: #f4005f } +.terminal-304540857-r4 { fill: #98e024;font-weight: bold } +.terminal-304540857-r5 { fill: #323232 } +.terminal-304540857-r6 { fill: #0178d4 } +.terminal-304540857-r7 { fill: #98e024 } +.terminal-304540857-r8 { fill: #7ae998 } +.terminal-304540857-r9 { fill: #4ebf71;font-weight: bold } +.terminal-304540857-r10 { fill: #008139 } +.terminal-304540857-r11 { fill: #fea62b;font-weight: bold } +.terminal-304540857-r12 { fill: #a7a9ab } +.terminal-304540857-r13 { fill: #e2e3e3 } +.terminal-304540857-r14 { fill: #4c5055 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ExampleApp + ExampleApp - + - - -Parent 1Parent 2 -━━━━━━━━━━━━╸━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - -Child 2.1Child 2.2 -━━━━━━━━━━━━━╸━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Button 2.2  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - SPACE Focus button 2.2 ^p palette + + +Parent 1Parent 2 +━━━━━━━━━━━━╸━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + +Child 2.1Child 2.2 +━━━━━━━━━━━━━╸━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Button 2.2  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + space Focus button 2.2 ^p palette diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_bind_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_bind_override.svg index d0e1bbf708..524cba14e9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_bind_override.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_bind_override.svg @@ -19,139 +19,139 @@ font-weight: 700; } - .terminal-1520345308-matrix { + .terminal-508010876-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1520345308-title { + .terminal-508010876-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1520345308-r1 { fill: #008000 } -.terminal-1520345308-r2 { fill: #c5c8c6 } -.terminal-1520345308-r3 { fill: #e1e1e1 } -.terminal-1520345308-r4 { fill: #1e1e1e } -.terminal-1520345308-r5 { fill: #e2e2e2 } -.terminal-1520345308-r6 { fill: #fea62b;font-weight: bold } -.terminal-1520345308-r7 { fill: #a7a9ab } -.terminal-1520345308-r8 { fill: #e2e3e3 } -.terminal-1520345308-r9 { fill: #4c5055 } + .terminal-508010876-r1 { fill: #008000 } +.terminal-508010876-r2 { fill: #c5c8c6 } +.terminal-508010876-r3 { fill: #e1e1e1 } +.terminal-508010876-r4 { fill: #1e1e1e } +.terminal-508010876-r5 { fill: #e2e2e2 } +.terminal-508010876-r6 { fill: #fea62b;font-weight: bold } +.terminal-508010876-r7 { fill: #a7a9ab } +.terminal-508010876-r8 { fill: #e2e3e3 } +.terminal-508010876-r9 { fill: #4c5055 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BindApp + BindApp - + - - ┌──────────────────────────────────────────────────────────────────────────────┐ -MyWidget - - -└──────────────────────────────────────────────────────────────────────────────┘ -▔▔▔▔▔▔▔▔ - -▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - SPACE Bell (Widget)  a widget  b widget  c app ^p palette + + ┌──────────────────────────────────────────────────────────────────────────────┐ +MyWidget + + +└──────────────────────────────────────────────────────────────────────────────┘ +▔▔▔▔▔▔▔▔ + +▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + space Bell (Widget)  a widget  b widget  c app ^p palette diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_classic_styling.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_classic_styling.svg index 05a2d698a3..ee0ad99610 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_classic_styling.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_classic_styling.svg @@ -19,135 +19,135 @@ font-weight: 700; } - .terminal-881099794-matrix { + .terminal-407601934-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-881099794-title { + .terminal-407601934-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-881099794-r1 { fill: #e1e1e1 } -.terminal-881099794-r2 { fill: #c5c8c6 } -.terminal-881099794-r3 { fill: #dde8f3;font-weight: bold } -.terminal-881099794-r4 { fill: #ddedf9 } -.terminal-881099794-r5 { fill: #308fd9 } + .terminal-407601934-r1 { fill: #e1e1e1 } +.terminal-407601934-r2 { fill: #c5c8c6 } +.terminal-407601934-r3 { fill: #dde8f3;font-weight: bold } +.terminal-407601934-r4 { fill: #ddedf9 } +.terminal-407601934-r5 { fill: #308fd9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ClassicFooterStylingApp + ClassicFooterStylingApp - - - - - - - - - - - - - - - - - - - - - - - - - - - CTRL+T  Toggle Dark mode  CTRL+Q  Quit                            ^p palette  + + + + + + + + + + + + + + + + + + + + + + + + + + + ^T  Toggle Dark mode  ^Q  Quit                                    ^p palette  diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_programmatic_disable_button.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_programmatic_disable_button.svg index cc8a137420..163c8a7c33 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_programmatic_disable_button.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_programmatic_disable_button.svg @@ -19,139 +19,139 @@ font-weight: 700; } - .terminal-400643882-matrix { + .terminal-3006158794-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-400643882-title { + .terminal-3006158794-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-400643882-r1 { fill: #e1e1e1 } -.terminal-400643882-r2 { fill: #c5c8c6 } -.terminal-400643882-r3 { fill: #303336 } -.terminal-400643882-r4 { fill: #a7a7a7;font-weight: bold } -.terminal-400643882-r5 { fill: #0f0f0f } -.terminal-400643882-r6 { fill: #fea62b;font-weight: bold } -.terminal-400643882-r7 { fill: #a7a9ab } -.terminal-400643882-r8 { fill: #e2e3e3 } -.terminal-400643882-r9 { fill: #4c5055 } + .terminal-3006158794-r1 { fill: #e1e1e1 } +.terminal-3006158794-r2 { fill: #c5c8c6 } +.terminal-3006158794-r3 { fill: #303336 } +.terminal-3006158794-r4 { fill: #a7a7a7;font-weight: bold } +.terminal-3006158794-r5 { fill: #0f0f0f } +.terminal-3006158794-r6 { fill: #fea62b;font-weight: bold } +.terminal-3006158794-r7 { fill: #a7a9ab } +.terminal-3006158794-r8 { fill: #e2e3e3 } +.terminal-3006158794-r9 { fill: #4c5055 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ExampleApp + ExampleApp - + - - - - - - - - - - -                        Hover the button then hit space                          -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Disabled  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - SPACE Toggle Button ^p palette + + + + + + + + + + +                        Hover the button then hit space                          +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Disabled  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + space Toggle Button ^p palette diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_split.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_split.svg new file mode 100644 index 0000000000..d1f69e5946 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_split.svg @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SplitApp + + + + + + + + + + + +#split3 + + + + + +1 + + + +#split4 +2 +#split1 + + + +3 + + + + + +#split2 + + + + + + + + diff --git a/tests/snapshot_tests/snapshot_apps/split.py b/tests/snapshot_tests/snapshot_apps/split.py new file mode 100644 index 0000000000..ed9febf703 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/split.py @@ -0,0 +1,48 @@ +from textual.app import App, ComposeResult +from textual.widgets import Placeholder + + +class SplitApp(App): + CSS = """ + + #split1 { + split: right; + width: 20; + } + + #split2 { + split: bottom; + height: 10; + } + + #split3 { + split: top; + height: 6; + } + + #split4 { + split: left; + width: 30; + } + + .scrollable { + height: 5; + } + + """ + + def compose(self) -> ComposeResult: + yield Placeholder(id="split1") + yield Placeholder(id="split2") + yield Placeholder(id="split3") + yield Placeholder(id="split4") + yield Placeholder("1", classes="scrollable") + yield Placeholder("2", classes="scrollable") + yield Placeholder("3", classes="scrollable") + yield Placeholder("4", classes="scrollable") + yield Placeholder("5", classes="scrollable") + + +if __name__ == "__main__": + app = SplitApp() + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index a818ab788e..80eb674a2d 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1438,3 +1438,8 @@ async def run_before(pilot: Pilot): assert snap_compare( SNAPSHOT_APPS_DIR / "command_palette_dismiss.py", run_before=run_before ) + + +def test_split(snap_compare): + """Test split rule.""" + assert snap_compare(SNAPSHOT_APPS_DIR / "split.py", terminal_size=(100, 30)) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 7f5ef264da..da3a35e026 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -504,3 +504,16 @@ def test_size_clamp_offset(): assert Size(3, 3).clamp_offset(Offset(3, 2)) == Offset(2, 2) assert Size(3, 3).clamp_offset(Offset(-3, 2)) == Offset(0, 2) assert Size(3, 3).clamp_offset(Offset(5, 4)) == Offset(2, 2) + + +@pytest.mark.parametrize( + ("region1", "region2", "expected"), + [ + (Region(0, 0, 100, 80), Region(0, 0, 100, 80), Spacing(0, 0, 0, 0)), + (Region(0, 0, 100, 80), Region(10, 10, 10, 10), Spacing(10, 80, 60, 10)), + ], +) +def test_get_spacing_between(region1: Region, region2: Region, expected: Spacing): + spacing = region1.get_spacing_between(region2) + assert spacing == expected + assert region1.shrink(spacing) == region2 diff --git a/tests/test_keys.py b/tests/test_keys.py index 54068b1901..5d9f8be194 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -64,8 +64,8 @@ def test_get_key_display_when_used_in_conjunction(): right_square_bracket = _get_key_display("right_square_bracket") ctrl_right_square_bracket = _get_key_display("ctrl+right_square_bracket") - assert ctrl_right_square_bracket == f"ctrl+{right_square_bracket}" + assert ctrl_right_square_bracket == f"^{right_square_bracket}" left = _get_key_display("left") ctrl_left = _get_key_display("ctrl+left") - assert ctrl_left == f"ctrl+{left}" + assert ctrl_left == f"^{left}"