From 1bdf38a34c05aadaf6f7cc490838c8bf8dfb63f3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Sep 2025 21:28:34 +0100 Subject: [PATCH 1/3] hover fix --- src/textual/_compositor.py | 7 +-- src/textual/app.py | 23 ++++++-- src/textual/binding.py | 3 ++ src/textual/design.py | 2 +- src/textual/layouts/horizontal.py | 1 - src/textual/layouts/stream.py | 6 +++ src/textual/layouts/vertical.py | 1 - src/textual/screen.py | 57 ++++++++++++++++++-- src/textual/strip.py | 3 +- src/textual/widgets/_footer.py | 88 +++++++++++++++++++++---------- 10 files changed, 146 insertions(+), 45 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6566aa759e..a473c2e3e4 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -848,9 +848,10 @@ def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: Sequence of (WIDGET, REGION) tuples. """ contains = Region.contains - for widget, cropped_region, region in self.layers_visible[y]: - if contains(cropped_region, x, y) and widget.visible: - yield widget, region + if len(self.layers_visible) > y >= 0: + for widget, cropped_region, region in self.layers_visible[y]: + if contains(cropped_region, x, y) and widget.visible: + yield widget, region def get_style_at(self, x: int, y: int) -> Style: """Get the Style at the given cell or Style.null() diff --git a/src/textual/app.py b/src/textual/app.py index 92748d0e7d..15e818858a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -617,6 +617,9 @@ def __init__( self._sync_available = False self.mouse_over: Widget | None = None + """The widget directly under the mouse.""" + self.hover_over: Widget | None = None + """The first widget with a hover style under the mouse.""" self.mouse_captured: Widget | None = None self._driver: Driver | None = None self._exit_renderables: list[RenderableType] = [] @@ -3010,7 +3013,9 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: """ self.screen.set_focus(widget, scroll_visible) - def _set_mouse_over(self, widget: Widget | None) -> None: + def _set_mouse_over( + self, widget: Widget | None, hover_widget: Widget | None + ) -> None: """Called when the mouse is over another widget. Args: @@ -3031,6 +3036,12 @@ def _set_mouse_over(self, widget: Widget | None) -> None: widget.post_message(events.Enter(widget)) finally: self.mouse_over = widget + if self.hover_over is not None: + self.hover_over.mouse_hover = False + if hover_widget is not None: + hover_widget.mouse_hover = True + + self.hover_over = hover_widget def _update_mouse_over(self, screen: Screen) -> None: """Updates the mouse over after the next refresh. @@ -3046,12 +3057,16 @@ def _update_mouse_over(self, screen: Screen) -> None: async def check_mouse() -> None: """Check if the mouse over widget has changed.""" try: - widget, _ = screen.get_widget_at(*self.mouse_position) + hover_widgets = screen.get_hover_widgets_at(*self.mouse_position) except NoWidget: pass else: - if widget is not self.mouse_over: - self._set_mouse_over(widget) + mouse_over, hover_over = hover_widgets.widgets + if ( + mouse_over is not self.mouse_over + or hover_over is not self.hover_over + ): + self._set_mouse_over(mouse_over, hover_over) self.call_after_refresh(check_mouse) diff --git a/src/textual/binding.py b/src/textual/binding.py index 345cc5f820..05e0161ab2 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -91,6 +91,9 @@ class Group: description: str = "" """Description of the group.""" + compact: bool = False + """Show keys in compact form (no spaces).""" + group: Group | None = None """Optional binding group (used to group related bindings in the footer).""" diff --git a/src/textual/design.py b/src/textual/design.py index 33d0e6c3e4..6aae080c1c 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -251,7 +251,7 @@ def luminosity_range(spread: float) -> Iterable[tuple[str, float]]: "block-cursor-blurred-text-style", "none" ) colors["block-hover-background"] = get( - "block-hover-background", boost.with_alpha(0.05).hex + "block-hover-background", boost.with_alpha(0.2).hex ) # The border color for focused widgets which have a border. diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index fdead10c1f..083a7210e1 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -83,7 +83,6 @@ def arrange( children, box_models, margins ): styles = widget.styles - overlay = styles.overlay == "screen" offset = ( styles.offset.resolve( diff --git a/src/textual/layouts/stream.py b/src/textual/layouts/stream.py index aa8b03e1a6..4bf92d558b 100644 --- a/src/textual/layouts/stream.py +++ b/src/textual/layouts/stream.py @@ -65,6 +65,12 @@ def arrange( if height < (max_height_value := int(max_height.value)) else max_height_value ) + if (min_height := styles.min_height) is not None and min_height.is_cells: + height = ( + height + if height > (min_height_value := int(min_height.value)) + else min_height_value + ) placements.append( _WidgetPlacement( _Region(left, y, width - (left + right), height), diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 4f68449417..da3462267d 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -105,7 +105,6 @@ def arrange( content_width.__floor__(), next_y.__floor__() - y.__floor__(), ) - absolute = styles.has_rule("position") and styles.position == "absolute" add_placement( _WidgetPlacement( diff --git a/src/textual/screen.py b/src/textual/screen.py index cefb45eeec..f3633d4711 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -20,6 +20,7 @@ Generic, Iterable, Iterator, + NamedTuple, Optional, TypeVar, Union, @@ -83,6 +84,23 @@ """Type of a screen result callback function.""" +class HoverWidgets(NamedTuple): + """Result of [get_hover_widget_at][textual.screen.Screen.get_hover_widget_at]""" + + mouse_over: tuple[Widget, Region] + """Widget and region directly under the mouse.""" + hover_over: tuple[Widget, Region] | None + """Widget with a hover style under the mouse, or `None` for no hover style widget.""" + + @property + def widgets(self) -> tuple[Widget, Widget | None]: + """Just the widgets.""" + return ( + self.mouse_over[0], + None if self.hover_over is None else self.hover_over[0], + ) + + @rich.repr.auto class ResultCallback(Generic[ScreenResultType]): """Holds the details of a callback.""" @@ -584,6 +602,33 @@ def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """ return self._compositor.get_widget_at(x, y) + def get_hover_widgets_at(self, x: int, y: int) -> HoverWidgets: + """Get the widget, and its region directly under the mouse, and the first + widget, region pair with a hover style. + + Args: + x: X Coordinate. + y: Y Coordinate. + + Returns: + A pair of (WIDGET, REGION) tuples for the top most and first hover style respectively. + + Raises: + NoWidget: If there is no widget under the screen coordinate. + + """ + widgets_under_coordinate = iter(self._compositor.get_widgets_at(x, y)) + try: + top_widget, top_region = next(widgets_under_coordinate) + except StopIteration: + raise errors.NoWidget(f"No hover widget under screen coordinate ({x}, {y})") + if not top_widget._has_hover_style: + for widget, region in widgets_under_coordinate: + if widget._has_hover_style: + return HoverWidgets((top_widget, top_region), (widget, region)) + return HoverWidgets((top_widget, top_region), None) + return HoverWidgets((top_widget, top_region), (top_widget, top_region)) + def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: """Get all widgets under a given coordinate. @@ -1380,7 +1425,7 @@ def _on_screen_suspend(self) -> None: """Screen has suspended.""" if self.app.SUSPENDED_SCREEN_CLASS: self.add_class(self.app.SUSPENDED_SCREEN_CLASS) - self.app._set_mouse_over(None) + self.app._set_mouse_over(None, None) self._clear_tooltip() self.stack_updates += 1 @@ -1492,14 +1537,17 @@ def _handle_tooltip_timer(self, widget: Widget) -> None: tooltip.update(tooltip_content) def _handle_mouse_move(self, event: events.MouseMove) -> None: + hover_widget: Widget | None = None try: if self.app.mouse_captured: widget = self.app.mouse_captured region = self.find_widget(widget).region else: - widget, region = self.get_widget_at(event.x, event.y) + (widget, region), hover = self.get_hover_widgets_at(event.x, event.y) + if hover is not None: + hover_widget = hover[0] except errors.NoWidget: - self.app._set_mouse_over(None) + self.app._set_mouse_over(None, None) if self._tooltip_timer is not None: self._tooltip_timer.stop() if not self.app._disable_tooltips: @@ -1507,9 +1555,8 @@ def _handle_mouse_move(self, event: events.MouseMove) -> None: self.get_child_by_type(Tooltip).display = False except NoMatches: pass - else: - self.app._set_mouse_over(widget) + self.app._set_mouse_over(widget, hover_widget) widget.hover_style = event.style if widget is self: self.post_message(event) diff --git a/src/textual/strip.py b/src/textual/strip.py index 891e3e6b72..d516cea8d8 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -690,7 +690,8 @@ def render_style(cls, style: Style, text: str, color_system: ColorSystem) -> str Returns: Text with ANSI escape sequences. """ - ansi = style._ansi or cls.render_ansi(style, color_system) + if (ansi := style._ansi) is None: + ansi = cls.render_ansi(style, color_system) output = f"\x1b[{ansi}m{text}\x1b[0m" if ansi else text if style._link: output = ( diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 07572e1b90..73cd81359f 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -11,7 +11,7 @@ from textual import events from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import ScrollableContainer +from textual.containers import HorizontalGroup, ScrollableContainer from textual.reactive import reactive from textual.widget import Widget from textual.widgets import Label @@ -20,6 +20,15 @@ from textual.screen import Screen +@rich.repr.auto +class KeyGroup(HorizontalGroup): + DEFAULT_CSS = """ + KeyGroup { + width: auto; + } + """ + + @rich.repr.auto class FooterKey(Widget): ALLOW_SELECT = False @@ -32,6 +41,7 @@ class FooterKey(Widget): FooterKey { width: auto; height: 1; + text-wrap: nowrap; background: $footer-item-background; .footer-key--key { color: $footer-key-foreground; @@ -87,6 +97,7 @@ def __init__( if disabled: classes += " -disabled" super().__init__(classes=classes) + self.shrink = False if tooltip: self.tooltip = tooltip @@ -98,6 +109,7 @@ def render(self) -> Text: description_padding = self.get_component_styles( "footer-key--description" ).padding + description = self.description if description: label_text = Text.assemble( @@ -144,7 +156,15 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): height: 1; scrollbar-size: 0 0; &.-compact { - grid-gutter: 1; + FooterLabel { + margin: 0; + } + FooterKey { + margin-left: 1; + } + FooterKey.-grouped { + margin: 0 1; + } } FooterKey.-command-palette { dock: right; @@ -156,6 +176,22 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): height: 1; layout: horizontal; } + KeyGroup.-compact { + FooterKey.-grouped { + margin: 0; + } + margin: 0 1 0 0; + padding-left: 1; + } + + FooterKey.-grouped { + margin: 0 1; + } + FooterLabel { + margin: 0 1 0 0; + color: $footer-description-foreground; + background: $footer-description-background; + } &:ansi { background: ansi_default; @@ -180,19 +216,11 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): border-left: vkey ansi_black; } } - FooterKey.-grouped { - margin: 0 1; - } - FooterLabel { - margin: 0 1; - background: red; - color: $footer-description-foreground; - background: $footer-description-background; - } + } """ - compact = reactive(False) + compact = reactive(False, toggle_class="-compact") """Display in compact style.""" _bindings_ready = reactive(False, repaint=False) """True if the bindings are ready to be displayed.""" @@ -209,6 +237,7 @@ def __init__( classes: str | None = None, disabled: bool = False, show_command_palette: bool = True, + compact: bool = False, ) -> None: """A footer to show key bindings. @@ -219,6 +248,7 @@ def __init__( classes: The CSS classes for the widget. disabled: Whether the widget is disabled or not. show_command_palette: Show key binding to invoke the command palette, on the right of the footer. + compact: Display a compact style (less whitespace) footer. """ super().__init__( *children, @@ -228,6 +258,7 @@ def __init__( disabled=disabled, ) self.set_reactive(Footer.show_command_palette, show_command_palette) + self.compact = compact def compose(self) -> ComposeResult: if not self._bindings_ready: @@ -247,23 +278,25 @@ def compose(self) -> ComposeResult: for group, multi_bindings_iterable in groupby( action_to_bindings.values(), - lambda multi_bindings: multi_bindings[0][0].group, + lambda multi_bindings_: multi_bindings_[0][0].group, ): - if group is not None: - for multi_bindings in multi_bindings_iterable: - binding, enabled, tooltip = multi_bindings[0] - yield FooterKey( - binding.key, - self.app.get_key_display(binding), - "", - binding.action, - disabled=not enabled, - tooltip=tooltip or binding.description, - classes="-grouped", - ).data_bind(Footer.compact) + multi_bindings = list(multi_bindings_iterable) + if group is not None and len(multi_bindings) > 1: + with KeyGroup(classes="-compact" if group.compact else ""): + for multi_bindings in multi_bindings: + binding, enabled, tooltip = multi_bindings[0] + yield FooterKey( + binding.key, + self.app.get_key_display(binding), + "", + binding.action, + disabled=not enabled, + tooltip=tooltip or binding.description, + classes="-grouped", + ).data_bind(Footer.compact) yield FooterLabel(group.description) else: - for multi_bindings in multi_bindings_iterable: + for multi_bindings in multi_bindings: binding, enabled, tooltip = multi_bindings[0] yield FooterKey( binding.key, @@ -324,6 +357,3 @@ def bindings_changed(screen: Screen) -> None: def on_unmount(self) -> None: self.screen.bindings_updated_signal.unsubscribe(self) - - def watch_compact(self, compact: bool) -> None: - self.set_class(compact, "-compact") From 494a617aed0bb381b06e01a052bd363dd047e94c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Sep 2025 19:21:31 +0100 Subject: [PATCH 2/3] snapshot tests --- CHANGELOG.md | 6 +++++- src/textual/design.py | 2 +- src/textual/widgets/_footer.py | 9 ++++++--- .../test_snapshots/test_footer_compact_with_hover.svg | 2 +- .../test_snapshots/test_footer_standard_with_hover.svg | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e02df616f8..e549af3661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Eager tasks are now enabled On Python3.12 and above https://github.com/Textualize/textual/pull/6102 - `Widget._arrange` is now public (as `Widget.arrange`) https://github.com/Textualize/textual/pull/6108 - Reduced number of layout operations required to update the screen https://github.com/Textualize/textual/pull/6108 +- The :hover pseudo-class no applies to the first widget under the mouse with a hover style set https://github.com/Textualize/textual/pull/6132 +- The footer key hover background is more visible https://github.com/Textualize/textual/pull/6132 ### Added -- Added `DOMNode.displayed_and_visible_children` https://github.com/Textualize/textual/pull/6102 +- Added `DOMNode.displayed_and_visible_children` https://github.com/Textualize/textual/pull/6102z - Added `Widget.process_layout` https://github.com/Textualize/textual/pull/6105 - Added `App.viewport_size` https://github.com/Textualize/textual/pull/6105 - Added `Screen.size` https://github.com/Textualize/textual/pull/6105 +- Added `compact` to Binding.Group https://github.com/Textualize/textual/pull/6132 +- Added `Screen.get_hover_widgets_at` https://github.com/Textualize/textual/pull/6132 ### Fixed diff --git a/src/textual/design.py b/src/textual/design.py index 6aae080c1c..f72dd7158c 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -251,7 +251,7 @@ def luminosity_range(spread: float) -> Iterable[tuple[str, float]]: "block-cursor-blurred-text-style", "none" ) colors["block-hover-background"] = get( - "block-hover-background", boost.with_alpha(0.2).hex + "block-hover-background", boost.with_alpha(0.1).hex ) # The border color for focused widgets which have a border. diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 73cd81359f..6a8ae5a4c0 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -160,11 +160,14 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): margin: 0; } FooterKey { - margin-left: 1; + margin-right: 1; } FooterKey.-grouped { margin: 0 1; } + FooterKey.-command-palette { + padding-right: 0; + } } FooterKey.-command-palette { dock: right; @@ -293,7 +296,7 @@ def compose(self) -> ComposeResult: disabled=not enabled, tooltip=tooltip or binding.description, classes="-grouped", - ).data_bind(Footer.compact) + ).data_bind(compact=Footer.compact) yield FooterLabel(group.description) else: for multi_bindings in multi_bindings: @@ -305,7 +308,7 @@ def compose(self) -> ComposeResult: binding.action, disabled=not enabled, tooltip=tooltip, - ).data_bind(Footer.compact) + ).data_bind(compact=Footer.compact) if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE: try: _node, binding, enabled, tooltip = active_bindings[ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_compact_with_hover.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_compact_with_hover.svg index aebc75aa4a..b57c371ff9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_compact_with_hover.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_compact_with_hover.svg @@ -121,7 +121,7 @@ - + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_standard_with_hover.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_standard_with_hover.svg index 3fb99d7636..d000c2f9ef 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_standard_with_hover.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_standard_with_hover.svg @@ -121,7 +121,7 @@ - + From 36d79ad44d43a96451e9e02620c06fecf58a6c06 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Sep 2025 19:29:28 +0100 Subject: [PATCH 3/3] z? --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e549af3661..6dffd1d483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added `DOMNode.displayed_and_visible_children` https://github.com/Textualize/textual/pull/6102z +- Added `DOMNode.displayed_and_visible_children` https://github.com/Textualize/textual/pull/6102 - Added `Widget.process_layout` https://github.com/Textualize/textual/pull/6105 - Added `App.viewport_size` https://github.com/Textualize/textual/pull/6105 - Added `Screen.size` https://github.com/Textualize/textual/pull/6105