Skip to content

Commit 4ddeae2

Browse files
authored
Merge pull request #4818 from Textualize/enter-bubble
enter bubble
2 parents 7acab1c + 9f12d16 commit 4ddeae2

File tree

9 files changed

+272
-18
lines changed

9 files changed

+272
-18
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
10+
### Added
11+
12+
- Added `Widget.is_mouse_over` https://github.com/Textualize/textual/pull/4818
13+
- Added `node` attribute to `events.Enter` and `events.Leave` https://github.com/Textualize/textual/pull/4818
14+
15+
### Changed
16+
17+
- `events.Enter` and `events.Leave` events now bubble. https://github.com/Textualize/textual/pull/4818
18+
- Renamed `Widget.mouse_over` to `Widget.mouse_hover` https://github.com/Textualize/textual/pull/4818
19+
820
## [0.74.0] - 2024-07-25
921

1022
### Fixed

docs/guide/input.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ Textual will send a [MouseCapture](../events/mouse_capture.md) event when the mo
232232

233233
Textual will send a [Enter](../events/enter.md) event to a widget when the mouse cursor first moves over it, and a [Leave](../events/leave.md) event when the cursor moves off a widget.
234234

235+
Both `Enter` and `Leave` _bubble_, so a widget may receive these events from a child widget.
236+
You can check the initial widget these events were sent to by comparing the `node` attribute against `self` in the message handler.
237+
235238
### Click events
236239

237240
There are three events associated with clicking a button on your mouse. When the button is initially pressed, Textual sends a [MouseDown](../events/mouse_down.md) event, followed by [MouseUp](../events/mouse_up.md) when the button is released. Textual then sends a final [Click](../events/click.md) event.

src/textual/app.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2304,16 +2304,16 @@ def _set_mouse_over(self, widget: Widget | None) -> None:
23042304
if widget is None:
23052305
if self.mouse_over is not None:
23062306
try:
2307-
self.mouse_over.post_message(events.Leave())
2307+
self.mouse_over.post_message(events.Leave(self.mouse_over))
23082308
finally:
23092309
self.mouse_over = None
23102310
else:
23112311
if self.mouse_over is not widget:
23122312
try:
23132313
if self.mouse_over is not None:
2314-
self.mouse_over.post_message(events.Leave())
2314+
self.mouse_over.post_message(events.Leave(self.mouse_over))
23152315
if widget is not None:
2316-
widget.post_message(events.Enter())
2316+
widget.post_message(events.Enter(widget))
23172317
finally:
23182318
self.mouse_over = widget
23192319

src/textual/events.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
MouseEventT = TypeVar("MouseEventT", bound="MouseEvent")
2828

2929
if TYPE_CHECKING:
30+
from .dom import DOMNode
3031
from .timer import Timer as TimerClass
3132
from .timer import TimerCallback
3233
from .widget import Widget
@@ -548,22 +549,44 @@ def __rich_repr__(self) -> rich.repr.Result:
548549
yield "count", self.count
549550

550551

551-
class Enter(Event, bubble=False, verbose=True):
552+
class Enter(Event, bubble=True, verbose=True):
552553
"""Sent when the mouse is moved over a widget.
553554
554-
- [ ] Bubbles
555+
Note that this event bubbles, so a widget may receive this event when the mouse
556+
moves over a child widget. Check the `node` attribute for the widget directly under
557+
the mouse.
558+
559+
- [X] Bubbles
555560
- [X] Verbose
556561
"""
557562

563+
__slots__ = ["node"]
558564

559-
class Leave(Event, bubble=False, verbose=True):
565+
def __init__(self, node: DOMNode) -> None:
566+
self.node = node
567+
"""The node directly under the mouse."""
568+
super().__init__()
569+
570+
571+
class Leave(Event, bubble=True, verbose=True):
560572
"""Sent when the mouse is moved away from a widget, or if a widget is
561573
programmatically disabled while hovered.
562574
563-
- [ ] Bubbles
575+
Note that this widget bubbles, so a widget may receive Leave events for any child widgets.
576+
Check the `node` parameter for the original widget that was previously under the mouse.
577+
578+
579+
- [X] Bubbles
564580
- [X] Verbose
565581
"""
566582

583+
__slots__ = ["node"]
584+
585+
def __init__(self, node: DOMNode) -> None:
586+
self.node = node
587+
"""The node that was previously directly under the mouse."""
588+
super().__init__()
589+
567590

568591
class Focus(Event, bubble=False):
569592
"""Sent when a widget is focussed.

src/textual/widget.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ def __init__(
428428
has_focus: Reactive[bool] = Reactive(False, repaint=False)
429429
"""Does this widget have focus? Read only."""
430430

431-
mouse_over: Reactive[bool] = Reactive(False, repaint=False)
431+
mouse_hover: Reactive[bool] = Reactive(False, repaint=False)
432432
"""Is the mouse over this widget? Read only."""
433433

434434
scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
@@ -542,6 +542,22 @@ def is_anchored(self) -> bool:
542542
"""Is this widget anchored?"""
543543
return self._parent is not None and self._parent is self
544544

545+
@property
546+
def is_mouse_over(self) -> bool:
547+
"""Is the mouse currently over this widget?
548+
549+
Note this will be `True` if the mouse pointer is within the widget's region, even if
550+
the mouse pointer is not directly over the widget (there could be another widget between
551+
the mouse pointer and self).
552+
553+
"""
554+
if not self.screen.is_active:
555+
return False
556+
for widget, _ in self.screen.get_widgets_at(*self.app.mouse_position):
557+
if widget is self:
558+
return True
559+
return False
560+
545561
def anchor(self, *, animate: bool = False) -> None:
546562
"""Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]),
547563
but also keeps it in view if the widget's size changes, or the size of its container changes.
@@ -3156,7 +3172,7 @@ def get_pseudo_classes(self) -> Iterable[str]:
31563172
Returns:
31573173
Names of the pseudo classes.
31583174
"""
3159-
if self.mouse_over:
3175+
if self.mouse_hover:
31603176
yield "hover"
31613177
if self.has_focus:
31623178
yield "focus"
@@ -3204,7 +3220,7 @@ def get_pseudo_class_state(self) -> PseudoClasses:
32043220

32053221
pseudo_classes = PseudoClasses(
32063222
enabled=not disabled,
3207-
hover=self.mouse_over,
3223+
hover=self.mouse_hover,
32083224
focus=self.has_focus,
32093225
)
32103226
return pseudo_classes
@@ -3248,7 +3264,7 @@ def post_render(self, renderable: RenderableType) -> ConsoleRenderable:
32483264

32493265
return renderable
32503266

3251-
def watch_mouse_over(self, value: bool) -> None:
3267+
def watch_mouse_hover(self, value: bool) -> None:
32523268
"""Update from CSS if mouse over state changes."""
32533269
if self._has_hover_style:
32543270
self._update_styles()
@@ -3261,9 +3277,9 @@ def watch_disabled(self, disabled: bool) -> None:
32613277
"""Update the styles of the widget and its children when disabled is toggled."""
32623278
from .app import ScreenStackError
32633279

3264-
if disabled and self.mouse_over:
3280+
if disabled and self.mouse_hover and self.app.mouse_over is not None:
32653281
# Ensure widget gets a Leave if it is disabled while hovered
3266-
self._message_queue.put_nowait(events.Leave())
3282+
self._message_queue.put_nowait(events.Leave(self.app.mouse_over))
32673283
try:
32683284
screen = self.screen
32693285
if (
@@ -3832,11 +3848,11 @@ def _on_mount(self, event: events.Mount) -> None:
38323848
self.show_horizontal_scrollbar = True
38333849

38343850
def _on_leave(self, event: events.Leave) -> None:
3835-
self.mouse_over = False
3851+
self.mouse_hover = False
38363852
self.hover_style = Style()
38373853

38383854
def _on_enter(self, event: events.Enter) -> None:
3839-
self.mouse_over = True
3855+
self.mouse_hover = True
38403856

38413857
def _on_focus(self, event: events.Focus) -> None:
38423858
self.has_focus = True
Lines changed: 151 additions & 0 deletions
Loading
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from textual import on
2+
from textual.app import App, ComposeResult
3+
from textual.widgets import Label
4+
from textual.widget import Widget
5+
from textual import events
6+
7+
8+
class MyWidget(Widget):
9+
def compose(self) -> ComposeResult:
10+
yield Label("Foo", id="foo")
11+
yield Label("Bar")
12+
13+
@on(events.Enter)
14+
@on(events.Leave)
15+
def on_enter_or_leave(self):
16+
self.set_class(self.is_mouse_over, "-over")
17+
18+
19+
class EnterApp(App):
20+
CSS = """
21+
22+
MyWidget {
23+
padding: 2 4;
24+
background: red;
25+
height: auto;
26+
width: auto;
27+
28+
&.-over {
29+
background: green;
30+
}
31+
32+
Label {
33+
background: rgba(20,20,200,0.5);
34+
}
35+
}
36+
"""
37+
38+
def compose(self) -> ComposeResult:
39+
yield MyWidget()
40+
41+
42+
if __name__ == "__main__":
43+
app = EnterApp()
44+
app.run()

0 commit comments

Comments
 (0)