Skip to content

Commit 6e9408c

Browse files
authored
Merge pull request #6132 from Textualize/hover-fix
WIP hover fix
2 parents 6bfb7e8 + 36d79ad commit 6e9408c

File tree

13 files changed

+156
-48
lines changed

13 files changed

+156
-48
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1212
- Eager tasks are now enabled On Python3.12 and above https://github.com/Textualize/textual/pull/6102
1313
- `Widget._arrange` is now public (as `Widget.arrange`) https://github.com/Textualize/textual/pull/6108
1414
- Reduced number of layout operations required to update the screen https://github.com/Textualize/textual/pull/6108
15+
- 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
16+
- The footer key hover background is more visible https://github.com/Textualize/textual/pull/6132
1517

1618
### Added
1719

1820
- Added `DOMNode.displayed_and_visible_children` https://github.com/Textualize/textual/pull/6102
1921
- Added `Widget.process_layout` https://github.com/Textualize/textual/pull/6105
2022
- Added `App.viewport_size` https://github.com/Textualize/textual/pull/6105
2123
- Added `Screen.size` https://github.com/Textualize/textual/pull/6105
24+
- Added `compact` to Binding.Group https://github.com/Textualize/textual/pull/6132
25+
- Added `Screen.get_hover_widgets_at` https://github.com/Textualize/textual/pull/6132
2226

2327
### Fixed
2428

src/textual/_compositor.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -848,9 +848,10 @@ def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
848848
Sequence of (WIDGET, REGION) tuples.
849849
"""
850850
contains = Region.contains
851-
for widget, cropped_region, region in self.layers_visible[y]:
852-
if contains(cropped_region, x, y) and widget.visible:
853-
yield widget, region
851+
if len(self.layers_visible) > y >= 0:
852+
for widget, cropped_region, region in self.layers_visible[y]:
853+
if contains(cropped_region, x, y) and widget.visible:
854+
yield widget, region
854855

855856
def get_style_at(self, x: int, y: int) -> Style:
856857
"""Get the Style at the given cell or Style.null()

src/textual/app.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,9 @@ def __init__(
617617
self._sync_available = False
618618

619619
self.mouse_over: Widget | None = None
620+
"""The widget directly under the mouse."""
621+
self.hover_over: Widget | None = None
622+
"""The first widget with a hover style under the mouse."""
620623
self.mouse_captured: Widget | None = None
621624
self._driver: Driver | None = None
622625
self._exit_renderables: list[RenderableType] = []
@@ -3010,7 +3013,9 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
30103013
"""
30113014
self.screen.set_focus(widget, scroll_visible)
30123015

3013-
def _set_mouse_over(self, widget: Widget | None) -> None:
3016+
def _set_mouse_over(
3017+
self, widget: Widget | None, hover_widget: Widget | None
3018+
) -> None:
30143019
"""Called when the mouse is over another widget.
30153020
30163021
Args:
@@ -3031,6 +3036,12 @@ def _set_mouse_over(self, widget: Widget | None) -> None:
30313036
widget.post_message(events.Enter(widget))
30323037
finally:
30333038
self.mouse_over = widget
3039+
if self.hover_over is not None:
3040+
self.hover_over.mouse_hover = False
3041+
if hover_widget is not None:
3042+
hover_widget.mouse_hover = True
3043+
3044+
self.hover_over = hover_widget
30343045

30353046
def _update_mouse_over(self, screen: Screen) -> None:
30363047
"""Updates the mouse over after the next refresh.
@@ -3046,12 +3057,16 @@ def _update_mouse_over(self, screen: Screen) -> None:
30463057
async def check_mouse() -> None:
30473058
"""Check if the mouse over widget has changed."""
30483059
try:
3049-
widget, _ = screen.get_widget_at(*self.mouse_position)
3060+
hover_widgets = screen.get_hover_widgets_at(*self.mouse_position)
30503061
except NoWidget:
30513062
pass
30523063
else:
3053-
if widget is not self.mouse_over:
3054-
self._set_mouse_over(widget)
3064+
mouse_over, hover_over = hover_widgets.widgets
3065+
if (
3066+
mouse_over is not self.mouse_over
3067+
or hover_over is not self.hover_over
3068+
):
3069+
self._set_mouse_over(mouse_over, hover_over)
30553070

30563071
self.call_after_refresh(check_mouse)
30573072

src/textual/binding.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ class Group:
9191
description: str = ""
9292
"""Description of the group."""
9393

94+
compact: bool = False
95+
"""Show keys in compact form (no spaces)."""
96+
9497
group: Group | None = None
9598
"""Optional binding group (used to group related bindings in the footer)."""
9699

src/textual/design.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ def luminosity_range(spread: float) -> Iterable[tuple[str, float]]:
251251
"block-cursor-blurred-text-style", "none"
252252
)
253253
colors["block-hover-background"] = get(
254-
"block-hover-background", boost.with_alpha(0.05).hex
254+
"block-hover-background", boost.with_alpha(0.1).hex
255255
)
256256

257257
# The border color for focused widgets which have a border.

src/textual/layouts/horizontal.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ def arrange(
8383
children, box_models, margins
8484
):
8585
styles = widget.styles
86-
8786
overlay = styles.overlay == "screen"
8887
offset = (
8988
styles.offset.resolve(

src/textual/layouts/stream.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ def arrange(
6565
if height < (max_height_value := int(max_height.value))
6666
else max_height_value
6767
)
68+
if (min_height := styles.min_height) is not None and min_height.is_cells:
69+
height = (
70+
height
71+
if height > (min_height_value := int(min_height.value))
72+
else min_height_value
73+
)
6874
placements.append(
6975
_WidgetPlacement(
7076
_Region(left, y, width - (left + right), height),

src/textual/layouts/vertical.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ def arrange(
105105
content_width.__floor__(),
106106
next_y.__floor__() - y.__floor__(),
107107
)
108-
109108
absolute = styles.has_rule("position") and styles.position == "absolute"
110109
add_placement(
111110
_WidgetPlacement(

src/textual/screen.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
Generic,
2121
Iterable,
2222
Iterator,
23+
NamedTuple,
2324
Optional,
2425
TypeVar,
2526
Union,
@@ -83,6 +84,23 @@
8384
"""Type of a screen result callback function."""
8485

8586

87+
class HoverWidgets(NamedTuple):
88+
"""Result of [get_hover_widget_at][textual.screen.Screen.get_hover_widget_at]"""
89+
90+
mouse_over: tuple[Widget, Region]
91+
"""Widget and region directly under the mouse."""
92+
hover_over: tuple[Widget, Region] | None
93+
"""Widget with a hover style under the mouse, or `None` for no hover style widget."""
94+
95+
@property
96+
def widgets(self) -> tuple[Widget, Widget | None]:
97+
"""Just the widgets."""
98+
return (
99+
self.mouse_over[0],
100+
None if self.hover_over is None else self.hover_over[0],
101+
)
102+
103+
86104
@rich.repr.auto
87105
class ResultCallback(Generic[ScreenResultType]):
88106
"""Holds the details of a callback."""
@@ -584,6 +602,33 @@ def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
584602
"""
585603
return self._compositor.get_widget_at(x, y)
586604

605+
def get_hover_widgets_at(self, x: int, y: int) -> HoverWidgets:
606+
"""Get the widget, and its region directly under the mouse, and the first
607+
widget, region pair with a hover style.
608+
609+
Args:
610+
x: X Coordinate.
611+
y: Y Coordinate.
612+
613+
Returns:
614+
A pair of (WIDGET, REGION) tuples for the top most and first hover style respectively.
615+
616+
Raises:
617+
NoWidget: If there is no widget under the screen coordinate.
618+
619+
"""
620+
widgets_under_coordinate = iter(self._compositor.get_widgets_at(x, y))
621+
try:
622+
top_widget, top_region = next(widgets_under_coordinate)
623+
except StopIteration:
624+
raise errors.NoWidget(f"No hover widget under screen coordinate ({x}, {y})")
625+
if not top_widget._has_hover_style:
626+
for widget, region in widgets_under_coordinate:
627+
if widget._has_hover_style:
628+
return HoverWidgets((top_widget, top_region), (widget, region))
629+
return HoverWidgets((top_widget, top_region), None)
630+
return HoverWidgets((top_widget, top_region), (top_widget, top_region))
631+
587632
def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
588633
"""Get all widgets under a given coordinate.
589634
@@ -1380,7 +1425,7 @@ def _on_screen_suspend(self) -> None:
13801425
"""Screen has suspended."""
13811426
if self.app.SUSPENDED_SCREEN_CLASS:
13821427
self.add_class(self.app.SUSPENDED_SCREEN_CLASS)
1383-
self.app._set_mouse_over(None)
1428+
self.app._set_mouse_over(None, None)
13841429
self._clear_tooltip()
13851430
self.stack_updates += 1
13861431

@@ -1492,24 +1537,26 @@ def _handle_tooltip_timer(self, widget: Widget) -> None:
14921537
tooltip.update(tooltip_content)
14931538

14941539
def _handle_mouse_move(self, event: events.MouseMove) -> None:
1540+
hover_widget: Widget | None = None
14951541
try:
14961542
if self.app.mouse_captured:
14971543
widget = self.app.mouse_captured
14981544
region = self.find_widget(widget).region
14991545
else:
1500-
widget, region = self.get_widget_at(event.x, event.y)
1546+
(widget, region), hover = self.get_hover_widgets_at(event.x, event.y)
1547+
if hover is not None:
1548+
hover_widget = hover[0]
15011549
except errors.NoWidget:
1502-
self.app._set_mouse_over(None)
1550+
self.app._set_mouse_over(None, None)
15031551
if self._tooltip_timer is not None:
15041552
self._tooltip_timer.stop()
15051553
if not self.app._disable_tooltips:
15061554
try:
15071555
self.get_child_by_type(Tooltip).display = False
15081556
except NoMatches:
15091557
pass
1510-
15111558
else:
1512-
self.app._set_mouse_over(widget)
1559+
self.app._set_mouse_over(widget, hover_widget)
15131560
widget.hover_style = event.style
15141561
if widget is self:
15151562
self.post_message(event)

src/textual/strip.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,8 @@ def render_style(cls, style: Style, text: str, color_system: ColorSystem) -> str
690690
Returns:
691691
Text with ANSI escape sequences.
692692
"""
693-
ansi = style._ansi or cls.render_ansi(style, color_system)
693+
if (ansi := style._ansi) is None:
694+
ansi = cls.render_ansi(style, color_system)
694695
output = f"\x1b[{ansi}m{text}\x1b[0m" if ansi else text
695696
if style._link:
696697
output = (

0 commit comments

Comments
 (0)