From ce84292365514320b32df1a0e6807903a5bdad75 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 12 Aug 2024 15:53:18 +0100 Subject: [PATCH 01/27] key panel widget --- examples/markdown.py | 15 +++-- src/textual/_compositor.py | 4 +- src/textual/system_commands.py | 5 -- src/textual/widgets/__init__.py | 3 + src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_footer.py | 1 + src/textual/widgets/_key_panel.py | 106 ++++++++++++++++++++++++++++++ 7 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 src/textual/widgets/_key_panel.py diff --git a/examples/markdown.py b/examples/markdown.py index 2cf43e4399..caad2920dc 100644 --- a/examples/markdown.py +++ b/examples/markdown.py @@ -4,17 +4,23 @@ 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 +from textual.widgets import Footer, KeyPanel, MarkdownViewer 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") @@ -27,6 +33,7 @@ def markdown_viewer(self) -> MarkdownViewer: def compose(self) -> ComposeResult: yield Footer() yield MarkdownViewer() + yield KeyPanel() async def on_mount(self) -> None: """Go to the first path when the app starts.""" diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 789cff7b58..ba62f6ba7a 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -647,7 +647,9 @@ def add_widget( layers_to_index = { layer_name: index - for index, layer_name in enumerate(widget.layers) + for index, layer_name in enumerate( + ("textual-low", *widget.layers, "textual-high") + ) } get_layer_index = layers_to_index.get diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py index 69cc17c953..7a10b3fa65 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -37,11 +37,6 @@ def _system_commands(self) -> tuple[tuple[str, IgnoreReturnCallbackType, str], . self.app.action_quit, "Quit the application as soon as possible", ), - ( - "Ring the bell", - self.app.action_bell, - "Ring the terminal's 'bell'", - ), ) async def discover(self) -> Hits: diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index a9505aa0dc..0b781601e6 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,8 @@ "Footer", "Header", "Input", + "KeyPanel", + "KeyPanel", "Label", "ListItem", "ListView", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 93b3af4d66..61332e6352 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 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 cb9559cea4..1ad4424583 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -145,6 +145,7 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): background: $panel; color: $text; dock: bottom; + layer: textual-high; height: 1; scrollbar-size: 0 0; &.-compact { diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py new file mode 100644 index 0000000000..4a97f59047 --- /dev/null +++ b/src/textual/widgets/_key_panel.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +from rich.table import Table +from rich.text import Text + +from ..binding import Binding +from ..reactive import reactive +from ..widgets import Static + +if TYPE_CHECKING: + from ..screen import Screen + + +class KeyPanel(Static): + COMPONENT_CLASSES = { + "footer-key--key", + "footer-key--description", + } + + DEFAULT_CSS = """ + KeyPanel { + layout: vertical; + dock: right; + # layer: textual-high; + width: 20; + # min-width: 20; + max-width: 33%; + # border-left: vkey $foreground 30%; + + padding: 0 1; + height: 1fr; + + border-left: vkey $primary; + + + padding-right: 1; + + &>.footer-key--key { + color: $secondary; + + text-style: bold; + padding: 0 1; + } + + &>.footer-key--description { + color: $text; + } + + + + + } + """ + + _bindings_ready = reactive(False, repaint=False, recompose=True) + + def update_bindings(self) -> None: + 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("footer-key--key") + description_style = self.get_component_rich_style("footer-key--description") + + def render_description(binding: Binding) -> Text: + 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), + style=key_style, + ), + render_description(binding), + ) + + self.update(table) + + def on_mount(self) -> None: + async def bindings_changed(screen: Screen) -> None: + self._bindings_ready = True + if self.is_attached and screen is self.screen: + self.update_bindings() + + self.screen.bindings_updated_signal.subscribe(self, bindings_changed) + + def on_unmount(self) -> None: + self.screen.bindings_updated_signal.unsubscribe(self) From 2e1de85b9c734075bf9bc09a2178a5d2d13a37bd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 13 Aug 2024 15:31:55 +0100 Subject: [PATCH 02/27] shrink containers --- src/textual/_compositor.py | 4 +-- src/textual/_layout.py | 6 ++-- src/textual/render.py | 15 +++++----- src/textual/widget.py | 5 +++- src/textual/widgets/_footer.py | 4 +-- src/textual/widgets/_key_panel.py | 48 ++++++++++++++++++------------- 6 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index ba62f6ba7a..789cff7b58 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -647,9 +647,7 @@ def add_widget( layers_to_index = { layer_name: index - for index, layer_name in enumerate( - ("textual-low", *widget.layers, "textual-high") - ) + for index, layer_name in enumerate(widget.layers) } get_layer_index = layers_to_index.get 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/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/widget.py b/src/textual/widget.py index f3f8196aed..3eb5538fba 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -389,6 +389,8 @@ def __init__( self._scrollbar_changes: set[tuple[bool, bool]] = set() """Used to stabilize scrollbars.""" + self.stretch = False + super().__init__( name=name, id=id, @@ -1233,6 +1235,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 +1351,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 +1370,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: diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 1ad4424583..81efbcb6c3 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -145,15 +145,13 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): background: $panel; color: $text; dock: bottom; - layer: textual-high; height: 1; scrollbar-size: 0 0; &.-compact { grid-gutter: 1; } FooterKey.-command-palette { - dock: right; - + dock: right; padding-right: 1; border-left: vkey $foreground 20%; } diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py index 4a97f59047..122448e2f8 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -6,7 +6,9 @@ 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 @@ -14,28 +16,23 @@ from ..screen import Screen -class KeyPanel(Static): - COMPONENT_CLASSES = { - "footer-key--key", - "footer-key--description", - } +class KeyPanel(VerticalScroll): + COMPONENT_CLASSES = {"footer-key--key", "footer-key--description"} DEFAULT_CSS = """ KeyPanel { - layout: vertical; + + dock: right; # layer: textual-high; - width: 20; - # min-width: 20; - max-width: 33%; - # border-left: vkey $foreground 30%; + width: auto; + + max-width: 40; + border-left: vkey $foreground 30%; - padding: 0 1; + padding: 1 1; height: 1fr; - border-left: vkey $primary; - - padding-right: 1; &>.footer-key--key { @@ -49,15 +46,17 @@ class KeyPanel(Static): color: $text; } - - + #bindings-table { + width: auto; + height: auto; + } } """ _bindings_ready = reactive(False, repaint=False, recompose=True) - def update_bindings(self) -> None: + def render_bindings_table(self) -> Table: bindings = [ (binding, enabled, tooltip) for (_, binding, enabled, tooltip) in self.screen.active_bindings.values() @@ -73,6 +72,7 @@ def update_bindings(self) -> None: description_style = self.get_component_rich_style("footer-key--description") def render_description(binding: Binding) -> Text: + """Render description text from a binding.""" text = Text.from_markup( binding.description, end="", style=description_style ) @@ -92,15 +92,23 @@ def render_description(binding: Binding) -> Text: render_description(binding), ) - self.update(table) + return table + + def compose(self) -> ComposeResult: + table = self.render_bindings_table() + self.log(table) + yield Static(table, id="bindings-table", shrink=True, expand=False) + + async def on_mount(self) -> None: + self.shrink = False - def on_mount(self) -> None: async def bindings_changed(screen: Screen) -> None: self._bindings_ready = True if self.is_attached and screen is self.screen: - self.update_bindings() + await self.recompose() self.screen.bindings_updated_signal.subscribe(self, bindings_changed) + await self.recompose() def on_unmount(self) -> None: self.screen.bindings_updated_signal.unsubscribe(self) From 749f24e26d15f47b0f258088e7aeff43698c1add Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 13 Aug 2024 15:40:40 +0100 Subject: [PATCH 03/27] tweak --- src/textual/widget.py | 2 +- src/textual/widgets/_key_panel.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 3eb5538fba..8543a9a3fa 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2000,7 +2000,7 @@ def layers(self) -> tuple[str, ...]: break if node.styles.has_rule("layers"): layers = node.styles.layers - + return ("textual-system-low", *layers, "textual-system-high") return layers @property diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py index 122448e2f8..55257261f4 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -24,8 +24,9 @@ class KeyPanel(VerticalScroll): dock: right; - # layer: textual-high; + # layer: textual-system-high; width: auto; + # overlay: screen; max-width: 40; border-left: vkey $foreground 30%; From 685d46a6e8298fdf0e4077488865b1cadf0cb02e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 14 Aug 2024 10:39:58 +0100 Subject: [PATCH 04/27] abbreviate keys --- src/textual/app.py | 10 ++++++++-- src/textual/keys.py | 24 +++++++++++++++++++----- src/textual/widget.py | 2 -- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 6bb0fbe827..7d0d2cec48 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.""" 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/widget.py b/src/textual/widget.py index 8543a9a3fa..f7aff7463a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -389,8 +389,6 @@ def __init__( self._scrollbar_changes: set[tuple[bool, bool]] = set() """Used to stabilize scrollbars.""" - self.stretch = False - super().__init__( name=name, id=id, From a44eb0b21770738f52803479c89d4c9c52089986 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 14 Aug 2024 14:54:09 +0100 Subject: [PATCH 05/27] split widgets --- src/textual/_arrange.py | 90 ++++++++++++++++++++++++---- src/textual/css/_help_text.py | 33 ++++++++++ src/textual/css/_style_properties.py | 31 ++++++++++ src/textual/css/_styles_builder.py | 14 +++++ src/textual/css/styles.py | 14 ++--- src/textual/widgets/_key_panel.py | 18 ++++-- tests/test_keys.py | 4 +- 7 files changed, 177 insertions(+), 27 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 97d8abbe4a..7b256e60c4 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -48,7 +48,9 @@ def arrange( placements: list[WidgetPlacement] = [] scroll_spacing = Spacing() get_dock = attrgetter("styles.dock") + get_split = attrgetter("styles.split") styles = widget.styles + null_spacing = Spacing() # Widgets which will be displayed display_widgets = [child for child in children if child.styles.display != "none"] @@ -56,22 +58,32 @@ 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 # 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) + else: + dock_spacing = null_spacing # Reduce the region to compensate for docked widgets - region = region.shrink(dock_spacing) + region = dock_region.shrink(dock_spacing) if layout_widgets: # Arrange layout widgets (i.e. not docked) @@ -103,20 +115,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 +146,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 +170,58 @@ 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 + widget_width = int(widget_width_fraction) + margin.width + widget_height = int(widget_height_fraction) + margin.height + + if split == "bottom": + split_region, view_region = view_region.split_horizontal(-widget_height) + elif split == "top": + view_region, split_region = view_region.split_horizontal(widget_height) + elif split == "left": + split_region, view_region = view_region.split_vertical(widget_width) + elif split == "right": + 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/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..e03cee39bf 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -748,6 +748,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/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py index 55257261f4..56037c4a99 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -23,12 +23,13 @@ class KeyPanel(VerticalScroll): KeyPanel { - dock: right; + split: right; # layer: textual-system-high; - width: auto; + width: 33%; + min-width: 30; # overlay: screen; - max-width: 40; + max-width: 60; border-left: vkey $foreground 30%; padding: 1 1; @@ -55,6 +56,10 @@ class KeyPanel(VerticalScroll): } """ + upper_case_keys = reactive(False) + """Upper case key display.""" + ctrl_to_caret = reactive(True) + """Convert 'ctrl+' prefix to '^'.""" _bindings_ready = reactive(False, repaint=False, recompose=True) def render_bindings_table(self) -> Table: @@ -87,7 +92,12 @@ def render_description(binding: Binding) -> Text: binding, enabled, tooltip = multi_bindings[0] table.add_row( Text( - binding.key_display or self.app.get_key_display(binding.key), + 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), 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}" From 888d8d92837e593908de8e954d24e2ad5e653f23 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 14 Aug 2024 16:39:08 +0100 Subject: [PATCH 06/27] Actions --- examples/markdown.py | 3 +-- src/textual/app.py | 13 +++++++++++++ src/textual/system_commands.py | 21 ++++++++++++++++++--- src/textual/widgets/__init__.pyi | 2 +- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/examples/markdown.py b/examples/markdown.py index caad2920dc..eb8bd16391 100644 --- a/examples/markdown.py +++ b/examples/markdown.py @@ -6,7 +6,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.reactive import var -from textual.widgets import Footer, KeyPanel, MarkdownViewer +from textual.widgets import Footer, MarkdownViewer class MarkdownApp(App): @@ -33,7 +33,6 @@ def markdown_viewer(self) -> MarkdownViewer: def compose(self) -> ComposeResult: yield Footer() yield MarkdownViewer() - yield KeyPanel() async def on_mount(self) -> None: """Go to the first path when the app starts.""" diff --git a/src/textual/app.py b/src/textual/app.py index 7d0d2cec48..aa8595bb59 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3504,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/system_commands.py b/src/textual/system_commands.py index 7a10b3fa65..a5c1011a10 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -24,9 +24,9 @@ class SystemCommands(Provider): """ @property - def _system_commands(self) -> tuple[tuple[str, IgnoreReturnCallbackType, str], ...]: + def _system_commands(self) -> list[tuple[str, IgnoreReturnCallbackType, str]]: """The system commands to reveal to the command palette.""" - return ( + commands = [ ( "Toggle light/dark mode", self.app.action_toggle_dark, @@ -37,7 +37,22 @@ def _system_commands(self) -> tuple[tuple[str, IgnoreReturnCallbackType, str], . self.app.action_quit, "Quit the application as soon as possible", ), - ) + ] + if self.screen.query("KeyPanel"): + commands.append( + ("Hide keys", self.app.action_hide_keys, "Hide the keys panel") + ) + else: + commands.append( + ( + "Show keys", + self.app.action_show_keys, + "Show a summary of available keys", + ) + ) + commands.sort(key=lambda command: command[0]) + + return commands async def discover(self) -> Hits: """Handle a request for the discovery commands for this provider. diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 61332e6352..f09f042d97 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -10,7 +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 +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 From 37e1d751b98e3e7ed06e30f89e6e55b5edaced72 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 14 Aug 2024 16:55:02 +0100 Subject: [PATCH 07/27] typing --- src/textual/system_commands.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py index a5c1011a10..37cbcce24c 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -13,6 +13,8 @@ from __future__ import annotations +from typing import Sequence + from .command import DiscoveryHit, Hit, Hits, Provider from .types import IgnoreReturnCallbackType @@ -24,7 +26,7 @@ class SystemCommands(Provider): """ @property - def _system_commands(self) -> list[tuple[str, IgnoreReturnCallbackType, str]]: + def _system_commands(self) -> Sequence[tuple[str, IgnoreReturnCallbackType, str]]: """The system commands to reveal to the command palette.""" commands = [ ( From 48f84608a043c3b10aba3d7b94e93df891bc4323 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 15 Aug 2024 16:09:44 +0100 Subject: [PATCH 08/27] palette tweak --- src/textual/command.py | 11 ++-- src/textual/widget.py | 1 - src/textual/widgets/_key_panel.py | 102 ++++++++++++++++-------------- 3 files changed, 62 insertions(+), 52 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index 44a03ca72c..24c4cb687a 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…") @@ -703,7 +702,7 @@ def _stop_no_matches_countdown(self) -> None: self._no_matches_timer.stop() self._no_matches_timer = None - _NO_MATCHES_COUNTDOWN: Final[float] = 0.5 + _NO_MATCHES_COUNTDOWN: Final[float] = 0.25 """How many seconds to wait before showing 'No matches found'.""" def _start_no_matches_countdown(self, search_value: str) -> None: @@ -1068,10 +1067,14 @@ 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: + self._action_command_list("select") else: # The list is visible, something is highlighted, the user # made a selection "gesture"; let's go select it! diff --git a/src/textual/widget.py b/src/textual/widget.py index f7aff7463a..0668e3bbc8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1998,7 +1998,6 @@ def layers(self) -> tuple[str, ...]: break if node.styles.has_rule("layers"): layers = node.styles.layers - return ("textual-system-low", *layers, "textual-system-high") return layers @property diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py index 56037c4a99..9721b03f0c 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -16,43 +16,13 @@ from ..screen import Screen -class KeyPanel(VerticalScroll): - COMPONENT_CLASSES = {"footer-key--key", "footer-key--description"} +class BindingsTable(Static): + COMPONENT_CLASSES = {"bindings-table--key", "bindings-table--description"} DEFAULT_CSS = """ - KeyPanel { - - - split: right; - # layer: textual-system-high; - width: 33%; - min-width: 30; - # overlay: screen; - - max-width: 60; - border-left: vkey $foreground 30%; - - padding: 1 1; - height: 1fr; - - padding-right: 1; - - &>.footer-key--key { - color: $secondary; - - text-style: bold; - padding: 0 1; - } - - &>.footer-key--description { - color: $text; - } - - #bindings-table { - width: auto; - height: auto; - } - + BindingsTable { + width: auto; + height: auto; } """ @@ -60,9 +30,13 @@ class KeyPanel(VerticalScroll): """Upper case key display.""" ctrl_to_caret = reactive(True) """Convert 'ctrl+' prefix to '^'.""" - _bindings_ready = reactive(False, repaint=False, recompose=True) 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() @@ -73,9 +47,8 @@ def render_bindings_table(self) -> Table: action_to_bindings[binding.action].append((binding, enabled, tooltip)) table = Table.grid(padding=(0, 1)) - - key_style = self.get_component_rich_style("footer-key--key") - description_style = self.get_component_rich_style("footer-key--description") + 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.""" @@ -105,21 +78,56 @@ def render_description(binding: Binding) -> Text: 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 '^'.""" + def compose(self) -> ComposeResult: - table = self.render_bindings_table() - self.log(table) - yield Static(table, id="bindings-table", shrink=True, expand=False) + yield BindingsTable(shrink=True, expand=False).data_bind( + KeyPanel.upper_case_keys, + KeyPanel.ctrl_to_caret, + ) async def on_mount(self) -> None: - self.shrink = False - async def bindings_changed(screen: Screen) -> None: - self._bindings_ready = True if self.is_attached and screen is self.screen: - await self.recompose() + self.refresh() self.screen.bindings_updated_signal.subscribe(self, bindings_changed) - await self.recompose() def on_unmount(self) -> None: self.screen.bindings_updated_signal.unsubscribe(self) From 11ec5a7ede145524bd90d1acfc21751ecdc0de43 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 15 Aug 2024 16:34:53 +0100 Subject: [PATCH 09/27] light / dark mode --- src/textual/command.py | 5 ++-- src/textual/system_commands.py | 51 +++++++++++++++++----------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index 24c4cb687a..f1296d711f 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -702,7 +702,7 @@ def _stop_no_matches_countdown(self) -> None: self._no_matches_timer.stop() self._no_matches_timer = None - _NO_MATCHES_COUNTDOWN: Final[float] = 0.25 + _NO_MATCHES_COUNTDOWN: Final[float] = 0.5 """How many seconds to wait before showing 'No matches found'.""" def _start_no_matches_countdown(self, search_value: str) -> None: @@ -1074,7 +1074,8 @@ def _select_or_command( self._action_cursor_down() # If there is one option, assume the user wants to select it if command_list.option_count == 1: - self._action_command_list("select") + # Call after a short delay to provide a little visual feedback + self.set_timer(0.1, lambda: self._action_command_list("select")) else: # The list is visible, something is highlighted, the user # made a selection "gesture"; let's go select it! diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py index 37cbcce24c..705303c29e 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -13,7 +13,7 @@ from __future__ import annotations -from typing import Sequence +from typing import Iterable from .command import DiscoveryHit, Hit, Hits, Provider from .types import IgnoreReturnCallbackType @@ -26,35 +26,35 @@ class SystemCommands(Provider): """ @property - def _system_commands(self) -> Sequence[tuple[str, IgnoreReturnCallbackType, str]]: + def _system_commands(self) -> Iterable[tuple[str, IgnoreReturnCallbackType, str]]: """The system commands to reveal to the command palette.""" - commands = [ - ( - "Toggle light/dark mode", + if self.app.dark: + yield ( + "Light 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", - ), - ] - if self.screen.query("KeyPanel"): - commands.append( - ("Hide keys", self.app.action_hide_keys, "Hide the keys panel") + "Switch to a light background", ) else: - commands.append( - ( - "Show keys", - self.app.action_show_keys, - "Show a summary of available keys", - ) + yield ( + "Dark mode", + self.app.action_toggle_dark, + "Switch to a dark background", ) - commands.sort(key=lambda command: command[0]) - return commands + 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. @@ -62,7 +62,8 @@ async def discover(self) -> Hits: 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, From 318c02300557efa991e4f845432d9c9dec083124 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 15 Aug 2024 19:39:08 +0100 Subject: [PATCH 10/27] typing Final --- src/textual/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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).""" From 3981af1a443a18b15fbab593237c0523779d40c5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 16 Aug 2024 15:27:43 +0100 Subject: [PATCH 11/27] fix arrange --- src/textual/_arrange.py | 11 ++++++----- src/textual/command.py | 12 +++--------- src/textual/dom.py | 2 -- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 7b256e60c4..0861e56a47 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -209,16 +209,17 @@ def _arrange_split_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 - if split == "bottom": - split_region, view_region = view_region.split_horizontal(-widget_height) + widget_height = int(widget_height_fraction) + margin.height + view_region, split_region = view_region.split_horizontal(-widget_height) elif split == "top": - view_region, split_region = view_region.split_horizontal(widget_height) + 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) diff --git a/src/textual/command.py b/src/textual/command.py index f1296d711f..ed3309b893 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -1099,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/dom.py b/src/textual/dom.py index 43275492cf..1d1c8b5698 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( From 39ed9a7b01804afba495579f10aa4bb7f8b9bd06 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 16 Aug 2024 15:28:00 +0100 Subject: [PATCH 12/27] snapshot --- src/textual/_system_screen.py | 29 +++++++++++++++ tests/snapshot_tests/snapshot_apps/split.py | 39 +++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/textual/_system_screen.py create mode 100644 tests/snapshot_tests/snapshot_apps/split.py diff --git a/src/textual/_system_screen.py b/src/textual/_system_screen.py new file mode 100644 index 0000000000..1b06f48781 --- /dev/null +++ b/src/textual/_system_screen.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .screen import Screen + +if TYPE_CHECKING: + from .app import ComposeResult + + +class SystemScreen(Screen): + DEFAULT_CSS = """ + SystemScreen { + Label { + height: 3; + color: red; + split: right; + } + + } + """ + + def compose(self) -> ComposeResult: + from .widgets import KeyPanel + + yield KeyPanel() + # from .widgets import Label + + # yield Label("Hello World ! 12345\n" * 200) diff --git a/tests/snapshot_tests/snapshot_apps/split.py b/tests/snapshot_tests/snapshot_apps/split.py new file mode 100644 index 0000000000..884262fe9e --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/split.py @@ -0,0 +1,39 @@ +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; + } + + """ + + def compose(self) -> ComposeResult: + yield Placeholder(id="split1") + yield Placeholder(id="split2") + yield Placeholder(id="split3") + yield Placeholder(id="split4") + yield Placeholder() + + +if __name__ == "__main__": + app = SplitApp() + app.run() From d2ccc36d8908833a5d743bf3e23e69394d5fa7c2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 16 Aug 2024 15:49:40 +0100 Subject: [PATCH 13/27] snapshot update --- src/textual/drivers/linux_driver.py | 4 +- tests/command_palette/test_escaping.py | 25 ---- .../test_snapshots/test_auto_tab_active.svg | 134 +++++++++--------- .../test_snapshots/test_bind_override.svg | 124 ++++++++-------- .../test_footer_classic_styling.svg | 118 +++++++-------- .../test_programmatic_disable_button.svg | 124 ++++++++-------- 6 files changed, 251 insertions(+), 278 deletions(-) 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/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 From 5940a4c8d1f15b9e877a36a553271739d239c7af Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 16 Aug 2024 15:55:14 +0100 Subject: [PATCH 14/27] default classes --- src/textual/widgets/_key_panel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py index 9721b03f0c..069fe7de76 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -17,6 +17,8 @@ class BindingsTable(Static): + """A widget to display bindings.""" + COMPONENT_CLASSES = {"bindings-table--key", "bindings-table--description"} DEFAULT_CSS = """ @@ -116,6 +118,8 @@ class KeyPanel(VerticalScroll, can_focus=False): 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, From 3fad7cf9027e08e4fd3940465da148df0ec812d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 16 Aug 2024 16:24:21 +0100 Subject: [PATCH 15/27] no need for this file --- src/textual/_system_screen.py | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 src/textual/_system_screen.py diff --git a/src/textual/_system_screen.py b/src/textual/_system_screen.py deleted file mode 100644 index 1b06f48781..0000000000 --- a/src/textual/_system_screen.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from .screen import Screen - -if TYPE_CHECKING: - from .app import ComposeResult - - -class SystemScreen(Screen): - DEFAULT_CSS = """ - SystemScreen { - Label { - height: 3; - color: red; - split: right; - } - - } - """ - - def compose(self) -> ComposeResult: - from .widgets import KeyPanel - - yield KeyPanel() - # from .widgets import Label - - # yield Label("Hello World ! 12345\n" * 200) From f863281db648e5e811f44ad9f283139aa4fd87df Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 16 Aug 2024 16:29:06 +0100 Subject: [PATCH 16/27] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ffcc3ba3..c41b89342e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ 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 ### Changed From e1b014f623d44ebaf3c60ff270187d1b1d21cdcf Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 17 Aug 2024 21:11:15 +0100 Subject: [PATCH 17/27] missing help --- src/textual/css/_styles_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index e03cee39bf..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, From bd3b063a3959d3d8e63901f6c0f30430ae5cf00a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 10:17:17 +0100 Subject: [PATCH 18/27] improved split --- src/textual/_arrange.py | 7 ++++++- src/textual/geometry.py | 16 ++++++++++++++++ tests/snapshot_tests/snapshot_apps/split.py | 11 ++++++++++- tests/snapshot_tests/test_snapshots.py | 5 +++++ tests/test_geometry.py | 13 +++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 0861e56a47..91dbff1d33 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -69,6 +69,8 @@ def arrange( 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, non_split_widgets) @@ -79,11 +81,14 @@ def arrange( dock_widgets, dock_region, viewport ) placements.extend(_dock_placements) + dock_region = dock_region.shrink(dock_spacing) else: dock_spacing = null_spacing # Reduce the region to compensate for docked widgets - region = dock_region.shrink(dock_spacing) + region = dock_region + + dock_spacing += split_spacing if layout_widgets: # Arrange layout widgets (i.e. not docked) 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/tests/snapshot_tests/snapshot_apps/split.py b/tests/snapshot_tests/snapshot_apps/split.py index 884262fe9e..ed9febf703 100644 --- a/tests/snapshot_tests/snapshot_apps/split.py +++ b/tests/snapshot_tests/snapshot_apps/split.py @@ -4,6 +4,7 @@ class SplitApp(App): CSS = """ + #split1 { split: right; width: 20; @@ -24,6 +25,10 @@ class SplitApp(App): width: 30; } + .scrollable { + height: 5; + } + """ def compose(self) -> ComposeResult: @@ -31,7 +36,11 @@ def compose(self) -> ComposeResult: yield Placeholder(id="split2") yield Placeholder(id="split3") yield Placeholder(id="split4") - yield Placeholder() + 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__": 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 From 6908ec03f9f84975c1ebc7631691f6e92eec75ad Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 10:27:07 +0100 Subject: [PATCH 19/27] snapshot --- src/textual/_arrange.py | 9 +- .../test_snapshots/test_split.svg | 181 ++++++++++++++++++ 2 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_split.svg diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 91dbff1d33..4c29f4fde2 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -85,9 +85,6 @@ def arrange( else: dock_spacing = null_spacing - # Reduce the region to compensate for docked widgets - region = dock_region - dock_spacing += split_spacing if layout_widgets: @@ -95,17 +92,17 @@ def arrange( 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: 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 + + + + + + + + From 70b4144571fd3141302f84a86f67946cb2092374 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 10:41:43 +0100 Subject: [PATCH 20/27] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c41b89342e..fecfdf2d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 From 870398c0270fde671dc96a047f14619f5251e33a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 10:49:42 +0100 Subject: [PATCH 21/27] simplify --- src/textual/_arrange.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 4c29f4fde2..52b1ea1c85 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -50,7 +50,6 @@ def arrange( get_dock = attrgetter("styles.dock") get_split = attrgetter("styles.split") styles = widget.styles - null_spacing = Spacing() # Widgets which will be displayed display_widgets = [child for child in children if child.styles.display != "none"] @@ -83,7 +82,7 @@ def arrange( placements.extend(_dock_placements) dock_region = dock_region.shrink(dock_spacing) else: - dock_spacing = null_spacing + dock_spacing = Spacing() dock_spacing += split_spacing @@ -94,9 +93,7 @@ def arrange( layout_widgets, dock_region.size, ) - scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) - placement_offset = dock_region.offset # Perform any alignment of the widgets. if styles.align_horizontal != "left" or styles.align_vertical != "top": From 40c48c5162c490aa3ed8308beafd17802a1149cc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 11:00:42 +0100 Subject: [PATCH 22/27] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fecfdf2d12..e7448d1f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### 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 From 289e4a919b26a3b7fa068248126b71a39e9b5230 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 11:17:15 +0100 Subject: [PATCH 23/27] require recompose --- src/textual/widgets/_key_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py index 069fe7de76..b58f905780 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -129,7 +129,7 @@ def compose(self) -> ComposeResult: async def on_mount(self) -> None: async def bindings_changed(screen: Screen) -> None: if self.is_attached and screen is self.screen: - self.refresh() + self.refresh(recompose=True) self.screen.bindings_updated_signal.subscribe(self, bindings_changed) From 82115b7aec735c92ff299df100b378a09f2539b1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 11:31:42 +0100 Subject: [PATCH 24/27] ws --- src/textual/widgets/_key_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py index b58f905780..3707e70be1 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -91,7 +91,7 @@ class KeyPanel(VerticalScroll, can_focus=False): width: 33%; min-width: 30; max-width: 60; - border-left: vkey $foreground 30%; + border-left: vkey $foreground 30%; padding: 1 1; height: 1fr; padding-right: 1; From ce07f7685a8e380deffd70e1c2ca88fa845b7873 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 11:37:20 +0100 Subject: [PATCH 25/27] don't update on app blur --- src/textual/widgets/_footer.py | 2 ++ src/textual/widgets/_key_panel.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index f30c84f727..3db09a2938 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -250,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 index 3707e70be1..7f4441322c 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -128,6 +128,8 @@ def compose(self) -> ComposeResult: 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) From b9b5a58f092ce3bb20aeb3e96fdd2f68e05c4b37 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 11:41:25 +0100 Subject: [PATCH 26/27] key panel twice --- src/textual/widgets/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 0b781601e6..bff80be2ce 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -61,7 +61,6 @@ "Header", "Input", "KeyPanel", - "KeyPanel", "Label", "ListItem", "ListView", From faad0f5cbbe924dfc7700e8becf62f9df2b25e1f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Aug 2024 13:30:08 +0100 Subject: [PATCH 27/27] remove timer --- src/textual/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/command.py b/src/textual/command.py index ed3309b893..2228a1d589 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -1075,7 +1075,7 @@ def _select_or_command( # 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.set_timer(0.1, lambda: self._action_command_list("select")) + self._action_command_list("select") else: # The list is visible, something is highlighted, the user # made a selection "gesture"; let's go select it!