Skip to content

Commit

Permalink
Merge pull request #4818 from Textualize/enter-bubble
Browse files Browse the repository at this point in the history
enter bubble
  • Loading branch information
willmcgugan authored Jul 29, 2024
2 parents 7acab1c + 9f12d16 commit 4ddeae2
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 18 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Added `Widget.is_mouse_over` https://github.com/Textualize/textual/pull/4818
- Added `node` attribute to `events.Enter` and `events.Leave` https://github.com/Textualize/textual/pull/4818

### Changed

- `events.Enter` and `events.Leave` events now bubble. https://github.com/Textualize/textual/pull/4818
- Renamed `Widget.mouse_over` to `Widget.mouse_hover` https://github.com/Textualize/textual/pull/4818

## [0.74.0] - 2024-07-25

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions docs/guide/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ Textual will send a [MouseCapture](../events/mouse_capture.md) event when the mo

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.

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

### Click events

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.
Expand Down
6 changes: 3 additions & 3 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2304,16 +2304,16 @@ def _set_mouse_over(self, widget: Widget | None) -> None:
if widget is None:
if self.mouse_over is not None:
try:
self.mouse_over.post_message(events.Leave())
self.mouse_over.post_message(events.Leave(self.mouse_over))
finally:
self.mouse_over = None
else:
if self.mouse_over is not widget:
try:
if self.mouse_over is not None:
self.mouse_over.post_message(events.Leave())
self.mouse_over.post_message(events.Leave(self.mouse_over))
if widget is not None:
widget.post_message(events.Enter())
widget.post_message(events.Enter(widget))
finally:
self.mouse_over = widget

Expand Down
31 changes: 27 additions & 4 deletions src/textual/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
MouseEventT = TypeVar("MouseEventT", bound="MouseEvent")

if TYPE_CHECKING:
from .dom import DOMNode
from .timer import Timer as TimerClass
from .timer import TimerCallback
from .widget import Widget
Expand Down Expand Up @@ -548,22 +549,44 @@ def __rich_repr__(self) -> rich.repr.Result:
yield "count", self.count


class Enter(Event, bubble=False, verbose=True):
class Enter(Event, bubble=True, verbose=True):
"""Sent when the mouse is moved over a widget.
- [ ] Bubbles
Note that this event bubbles, so a widget may receive this event when the mouse
moves over a child widget. Check the `node` attribute for the widget directly under
the mouse.
- [X] Bubbles
- [X] Verbose
"""

__slots__ = ["node"]

class Leave(Event, bubble=False, verbose=True):
def __init__(self, node: DOMNode) -> None:
self.node = node
"""The node directly under the mouse."""
super().__init__()


class Leave(Event, bubble=True, verbose=True):
"""Sent when the mouse is moved away from a widget, or if a widget is
programmatically disabled while hovered.
- [ ] Bubbles
Note that this widget bubbles, so a widget may receive Leave events for any child widgets.
Check the `node` parameter for the original widget that was previously under the mouse.
- [X] Bubbles
- [X] Verbose
"""

__slots__ = ["node"]

def __init__(self, node: DOMNode) -> None:
self.node = node
"""The node that was previously directly under the mouse."""
super().__init__()


class Focus(Event, bubble=False):
"""Sent when a widget is focussed.
Expand Down
32 changes: 24 additions & 8 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ def __init__(
has_focus: Reactive[bool] = Reactive(False, repaint=False)
"""Does this widget have focus? Read only."""

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

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

@property
def is_mouse_over(self) -> bool:
"""Is the mouse currently over this widget?
Note this will be `True` if the mouse pointer is within the widget's region, even if
the mouse pointer is not directly over the widget (there could be another widget between
the mouse pointer and self).
"""
if not self.screen.is_active:
return False
for widget, _ in self.screen.get_widgets_at(*self.app.mouse_position):
if widget is self:
return True
return False

def anchor(self, *, animate: bool = False) -> None:
"""Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]),
but also keeps it in view if the widget's size changes, or the size of its container changes.
Expand Down Expand Up @@ -3156,7 +3172,7 @@ def get_pseudo_classes(self) -> Iterable[str]:
Returns:
Names of the pseudo classes.
"""
if self.mouse_over:
if self.mouse_hover:
yield "hover"
if self.has_focus:
yield "focus"
Expand Down Expand Up @@ -3204,7 +3220,7 @@ def get_pseudo_class_state(self) -> PseudoClasses:

pseudo_classes = PseudoClasses(
enabled=not disabled,
hover=self.mouse_over,
hover=self.mouse_hover,
focus=self.has_focus,
)
return pseudo_classes
Expand Down Expand Up @@ -3248,7 +3264,7 @@ def post_render(self, renderable: RenderableType) -> ConsoleRenderable:

return renderable

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

if disabled and self.mouse_over:
if disabled and self.mouse_hover and self.app.mouse_over is not None:
# Ensure widget gets a Leave if it is disabled while hovered
self._message_queue.put_nowait(events.Leave())
self._message_queue.put_nowait(events.Leave(self.app.mouse_over))
try:
screen = self.screen
if (
Expand Down Expand Up @@ -3832,11 +3848,11 @@ def _on_mount(self, event: events.Mount) -> None:
self.show_horizontal_scrollbar = True

def _on_leave(self, event: events.Leave) -> None:
self.mouse_over = False
self.mouse_hover = False
self.hover_style = Style()

def _on_enter(self, event: events.Enter) -> None:
self.mouse_over = True
self.mouse_hover = True

def _on_focus(self, event: events.Focus) -> None:
self.has_focus = True
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions tests/snapshot_tests/snapshot_apps/enter_or_leave.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Label
from textual.widget import Widget
from textual import events


class MyWidget(Widget):
def compose(self) -> ComposeResult:
yield Label("Foo", id="foo")
yield Label("Bar")

@on(events.Enter)
@on(events.Leave)
def on_enter_or_leave(self):
self.set_class(self.is_mouse_over, "-over")


class EnterApp(App):
CSS = """
MyWidget {
padding: 2 4;
background: red;
height: auto;
width: auto;
&.-over {
background: green;
}
Label {
background: rgba(20,20,200,0.5);
}
}
"""

def compose(self) -> ComposeResult:
yield MyWidget()


if __name__ == "__main__":
app = EnterApp()
app.run()
Loading

0 comments on commit 4ddeae2

Please sign in to comment.