Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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

Expand Down
7 changes: 4 additions & 3 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
23 changes: 19 additions & 4 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down
2 changes: 1 addition & 1 deletion src/textual/design.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.1).hex
)

# The border color for focused widgets which have a border.
Expand Down
1 change: 0 additions & 1 deletion src/textual/layouts/horizontal.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ def arrange(
children, box_models, margins
):
styles = widget.styles

overlay = styles.overlay == "screen"
offset = (
styles.offset.resolve(
Expand Down
6 changes: 6 additions & 0 deletions src/textual/layouts/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 0 additions & 1 deletion src/textual/layouts/vertical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
57 changes: 52 additions & 5 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Generic,
Iterable,
Iterator,
NamedTuple,
Optional,
TypeVar,
Union,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -1492,24 +1537,26 @@ 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:
try:
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)
Expand Down
3 changes: 2 additions & 1 deletion src/textual/strip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
Loading
Loading