Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix stuck hover state #4724

Merged
merged 6 commits into from
Jul 11, 2024
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

- Fixed issue with `Tabs` where disabled tabs could still be activated by clicking the underline https://github.com/Textualize/textual/issues/4701
- Fixed scroll_visible with margin https://github.com/Textualize/textual/pull/4719
- Fixed programmatically disabling button stuck in hover state https://github.com/Textualize/textual/pull/4724

### Changed

Expand Down
3 changes: 2 additions & 1 deletion src/textual/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,8 @@ class Enter(Event, bubble=False, verbose=True):


class Leave(Event, bubble=False, verbose=True):
"""Sent when the mouse is moved away from a widget.
"""Sent when the mouse is moved away from a widget, or if a widget is
programmatically disabled while hovered.

- [ ] Bubbles
- [X] Verbose
Expand Down
4 changes: 3 additions & 1 deletion src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ def check_message_enabled(self, message: Message) -> bool:
Returns:
`True` if the message will be sent, or `False` if it is disabled.
"""
return type(message) not in self._disabled_messages

enabled = type(message) not in self._disabled_messages
return enabled

def disable_messages(self, *messages: type[Message]) -> None:
"""Disable message types from being processed."""
Expand Down
17 changes: 11 additions & 6 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -3234,19 +3234,24 @@ def watch_has_focus(self, value: bool) -> None:
"""Update from CSS if has focus state changes."""
self._update_styles()

def watch_disabled(self) -> None:
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:
# Ensure widget gets a Leave if it is disabled while hovered
self._message_queue.put_nowait(events.Leave())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block all makes sense to me other than posting the Leave event, because the mouse has not left the widget.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because when a widget is disabled, it no longer receives Enter and Leave events. So a widget that is hovered and programmatically disabled will have received an Enter, but will never get a corresponding Leave. So whatever the widget does on enter is stuck even if the cursor is no longer over the widget.

Sending the Leave here ensures that the widget goes back to its unhovered state when disabled.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense after the explanation in the first paragraph, thanks. Probably worth a small update to the Leave docstring.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

try:
screen = self.screen
if (
self.disabled
and self.app.focused is not None
and self in self.app.focused.ancestors_with_self
disabled
and screen.focused is not None
and self in screen.focused.ancestors_with_self
):
self.app.focused.blur()
except (ScreenStackError, NoActiveAppError):
screen.focused.blur()
except (ScreenStackError, NoActiveAppError, NoScreen):
pass

self._update_styles()

def _size_updated(
Expand Down
161 changes: 161 additions & 0 deletions tests/snapshot_tests/__snapshots__/test_snapshots.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -32353,6 +32353,167 @@

'''
# ---
# name: test_programmatic_disable_button
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="https://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>

@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}

.terminal-2109104343-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}

.terminal-2109104343-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}

.terminal-2109104343-r1 { fill: #e1e1e1 }
.terminal-2109104343-r2 { fill: #c5c8c6 }
.terminal-2109104343-r3 { fill: #303336 }
.terminal-2109104343-r4 { fill: #a7a7a7;font-weight: bold }
.terminal-2109104343-r5 { fill: #0f0f0f }
.terminal-2109104343-r6 { fill: #fea62b;font-weight: bold }
.terminal-2109104343-r7 { fill: #a7a9ab }
.terminal-2109104343-r8 { fill: #e2e3e3 }
</style>

<defs>
<clipPath id="terminal-2109104343-clip-terminal">
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
</clipPath>
<clipPath id="terminal-2109104343-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-19">
<rect x="0" y="465.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-20">
<rect x="0" y="489.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-21">
<rect x="0" y="513.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-2109104343-line-22">
<rect x="0" y="538.3" width="976" height="24.65"/>
</clipPath>
</defs>

<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-2109104343-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ExampleApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>

<g transform="translate(9, 41)" clip-path="url(#terminal-2109104343-clip-terminal)">
<rect fill="#1e1e1e" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#202225" x="390.4" y="245.5" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="585.6" y="245.5" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#202225" x="390.4" y="269.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#202225" x="427" y="269.9" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#202225" x="549" y="269.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="585.6" y="269.9" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#202225" x="390.4" y="294.3" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="585.6" y="294.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="562.7" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="85.4" y="562.7" width="170.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="256.2" y="562.7" width="719.8" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-2109104343-matrix">
<text class="terminal-2109104343-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-2109104343-line-0)">
</text><text class="terminal-2109104343-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-2109104343-line-1)">
</text><text class="terminal-2109104343-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-2109104343-line-2)">
</text><text class="terminal-2109104343-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-2109104343-line-3)">
</text><text class="terminal-2109104343-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-2109104343-line-4)">
</text><text class="terminal-2109104343-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-2109104343-line-5)">
</text><text class="terminal-2109104343-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-2109104343-line-6)">
</text><text class="terminal-2109104343-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-2109104343-line-7)">
</text><text class="terminal-2109104343-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-2109104343-line-8)">
</text><text class="terminal-2109104343-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-2109104343-line-9)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Hover&#160;the&#160;button&#160;then&#160;hit&#160;space&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-2109104343-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-2109104343-line-9)">
</text><text class="terminal-2109104343-r3" x="390.4" y="264" textLength="195.2" clip-path="url(#terminal-2109104343-line-10)">▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔</text><text class="terminal-2109104343-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-2109104343-line-10)">
</text><text class="terminal-2109104343-r4" x="427" y="288.4" textLength="122" clip-path="url(#terminal-2109104343-line-11)">&#160;Disabled&#160;</text><text class="terminal-2109104343-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-2109104343-line-11)">
</text><text class="terminal-2109104343-r5" x="390.4" y="312.8" textLength="195.2" clip-path="url(#terminal-2109104343-line-12)">▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁</text><text class="terminal-2109104343-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-2109104343-line-12)">
</text><text class="terminal-2109104343-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-2109104343-line-13)">
</text><text class="terminal-2109104343-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-2109104343-line-14)">
</text><text class="terminal-2109104343-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-2109104343-line-15)">
</text><text class="terminal-2109104343-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-2109104343-line-16)">
</text><text class="terminal-2109104343-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-2109104343-line-17)">
</text><text class="terminal-2109104343-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-2109104343-line-18)">
</text><text class="terminal-2109104343-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-2109104343-line-19)">
</text><text class="terminal-2109104343-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-2109104343-line-20)">
</text><text class="terminal-2109104343-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-2109104343-line-21)">
</text><text class="terminal-2109104343-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-2109104343-line-22)">
</text><text class="terminal-2109104343-r6" x="0" y="581.2" textLength="85.4" clip-path="url(#terminal-2109104343-line-23)">&#160;SPACE&#160;</text><text class="terminal-2109104343-r7" x="85.4" y="581.2" textLength="170.8" clip-path="url(#terminal-2109104343-line-23)">Toggle&#160;Button&#160;</text>
</g>
</g>
</svg>

'''
# ---
# name: test_programmatic_scrollbar_gutter_change
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="https://www.w3.org/2000/svg">
Expand Down
35 changes: 35 additions & 0 deletions tests/snapshot_tests/snapshot_apps/programmatic_disable_button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from textual.app import App, ComposeResult
from textual.containers import Center
from textual.widgets import Button, Footer, Label


class ExampleApp(App):
CSS = """
Screen {
align: center middle;
}
"""

BINDINGS = [("space", "toggle_button", "Toggle Button")]

def compose(self) -> ComposeResult:
with Center():
yield Label("Hover the button then hit space")
with Center():
yield Button("Enabled", id="disable-btn")
yield Footer()

def action_toggle_button(self) -> None:
self.app.bell()
button = self.query_one("#disable-btn", Button)
if button.disabled is False:
button.disabled = True
button.label = "Disabled"
else:
button.disabled = False
button.label = "Enabled"


if __name__ == "__main__":
app = ExampleApp()
app.run()
13 changes: 13 additions & 0 deletions tests/snapshot_tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

from tests.snapshot_tests.language_snippets import SNIPPETS
from textual.pilot import Pilot
from textual.widgets.text_area import Selection, BUILTIN_LANGUAGES
from textual.widgets import RichLog, TextArea, Input, Button
from textual.widgets.text_area import TextAreaTheme
Expand Down Expand Up @@ -1309,3 +1310,15 @@ def test_bindings_screen_overrides_show(snap_compare):
def test_scroll_visible_with_margin(snap_compare):
"""Regression test for https://github.com/Textualize/textual/issues/2181"""
assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_visible_margin.py", press=["x"])


def test_programmatic_disable_button(snap_compare):
"""Regression test for https://github.com/Textualize/textual/issues/3130"""

async def run_before(pilot: Pilot) -> None:
await pilot.hover("#disable-btn")
await pilot.press("space")

assert snap_compare(
SNAPSHOT_APPS_DIR / "programmatic_disable_button.py", run_before=run_before
)
Loading