From fd958fa4e1b45eaa1858651c26f3fcfb80a3739e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 12:18:33 +0100 Subject: [PATCH 1/5] delay update for footer --- CHANGELOG.md | 1 + src/textual/app.py | 4 ++-- src/textual/command.py | 2 +- src/textual/widgets/_footer.py | 8 ++------ 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dffd1d483..8b0d52feba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 +- Made `App.delay_update` public ### Added diff --git a/src/textual/app.py b/src/textual/app.py index 15e818858a..befa6df397 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1019,7 +1019,7 @@ def _end_batch(self) -> None: if not self._batch_count: self.check_idle() - def _delay_update(self, delay: float = 0.05) -> None: + def delay_update(self, delay: float = 0.05) -> None: """Delay updates for a short period of time. May be used to mask a brief transition. @@ -1035,7 +1035,7 @@ def end_batch() -> None: if not self._batch_count: self.screen.refresh() - self.set_timer(delay, end_batch, name="_delay_update") + self.set_timer(delay, end_batch, name="delay_update") @contextmanager def _context(self) -> Generator[None, None, None]: diff --git a/src/textual/command.py b/src/textual/command.py index a05401e999..c0d3c415ff 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -1230,7 +1230,7 @@ def _select_or_command( # decide what to do with it (hopefully it'll run it). self._cancel_gather_commands() self.app.post_message(CommandPalette.Closed(option_selected=True)) - self.app._delay_update() + self.app.delay_update() self.dismiss() self.app.call_later(self._selected_command.command) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 6a8ae5a4c0..3d29536a82 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -331,6 +331,7 @@ async def bindings_changed(self, screen: Screen) -> None: self._bindings_ready = True if not screen.app.app_focus: return + self.app.delay_update() if self.is_attached and screen is self.screen: await self.recompose() @@ -351,12 +352,7 @@ def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: async def on_mount(self) -> None: await asyncio.sleep(0) self.call_next(self.bindings_changed, self.screen) - - def bindings_changed(screen: Screen) -> None: - """Update bindings after a short delay to avoid flicker.""" - self.call_after_refresh(self.bindings_changed, screen) - - self.screen.bindings_updated_signal.subscribe(self, bindings_changed) + self.screen.bindings_updated_signal.subscribe(self, self.bindings_changed) def on_unmount(self) -> None: self.screen.bindings_updated_signal.unsubscribe(self) From 377933083c4b97b29421d2f7bb4b083853774b36 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 12:21:56 +0100 Subject: [PATCH 2/5] doc --- CHANGELOG.md | 2 +- src/textual/app.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0d52feba..f66554d180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 -- Made `App.delay_update` public +- Made `App.delay_update` public https://github.com/Textualize/textual/pull/6137 ### Added diff --git a/src/textual/app.py b/src/textual/app.py index befa6df397..2b04f3df9e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1023,6 +1023,7 @@ def delay_update(self, delay: float = 0.05) -> None: """Delay updates for a short period of time. May be used to mask a brief transition. + Consider this method only if you aren't able to use `App.batch_update`. Args: delay: Delay before updating. From 1736cd1c09433b93c83460bedb7fd840dafc375b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 12:54:54 +0100 Subject: [PATCH 3/5] better jank fix --- src/textual/widgets/_footer.py | 3 +-- src/textual/widgets/_help_panel.py | 5 ++++- src/textual/widgets/_key_panel.py | 10 +++++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 3d29536a82..95159286df 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -331,9 +331,8 @@ async def bindings_changed(self, screen: Screen) -> None: self._bindings_ready = True if not screen.app.app_focus: return - self.app.delay_update() if self.is_attached and screen is self.screen: - await self.recompose() + self.call_after_refresh(self.recompose) def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if self.allow_horizontal_scroll: diff --git a/src/textual/widgets/_help_panel.py b/src/textual/widgets/_help_panel.py index 7b8da2e7c2..36717d2445 100644 --- a/src/textual/widgets/_help_panel.py +++ b/src/textual/widgets/_help_panel.py @@ -79,7 +79,10 @@ class HelpPanel(Widget): DEFAULT_CLASSES = "-textual-system" def on_mount(self): - self.watch(self.screen, "focused", self.update_help) + def update_help(focused_widget: Widget | None): + self.update_help(focused_widget) + + self.watch(self.screen, "focused", update_help) def update_help(self, focused_widget: Widget | None) -> None: """Update the help for the focused widget. diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py index 39bc8322bd..4794c6cb92 100644 --- a/src/textual/widgets/_key_panel.py +++ b/src/textual/widgets/_key_panel.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections import defaultdict -from functools import partial from itertools import groupby from operator import itemgetter from typing import TYPE_CHECKING @@ -164,16 +163,17 @@ def compose(self) -> ComposeResult: yield BindingsTable(shrink=True, expand=False) async def on_mount(self) -> None: + mount_screen = self.screen + async def bindings_changed(screen: Screen) -> None: """Update bindings.""" if not screen.app.app_focus: return - if self.is_attached and screen is self.screen: - self.refresh(recompose=True) + if self.is_attached and screen is mount_screen: + await self.recompose() def _bindings_changed(screen: Screen) -> None: - """Update bindings after a short delay.""" - screen.set_timer(1 / 20, partial(bindings_changed, screen)) + self.call_after_refresh(bindings_changed, screen) self.set_class(self.app.ansi_color, "-ansi-scrollbar") self.screen.bindings_updated_signal.subscribe(self, _bindings_changed) From 26610ef5f84fd0a36770337f3fd063763be8886e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 15:10:23 +0100 Subject: [PATCH 4/5] wait for refresh before click --- src/textual/pilot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 73cff9413a..314eff712a 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -230,6 +230,7 @@ async def click( True if no selector was specified or if the click landed on the selected widget, False otherwise. """ + await self.app.wait_for_refresh() try: return await self._post_mouse_events( [MouseDown, MouseUp, Click], From 6b982019a3f9ac8484eded31054f1311f5c750be Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 15:37:05 +0100 Subject: [PATCH 5/5] more pauses --- src/textual/pilot.py | 1 - src/textual/widgets/_footer.py | 2 +- tests/test_modal.py | 5 +++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 314eff712a..73cff9413a 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -230,7 +230,6 @@ async def click( True if no selector was specified or if the click landed on the selected widget, False otherwise. """ - await self.app.wait_for_refresh() try: return await self._post_mouse_events( [MouseDown, MouseUp, Click], diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 95159286df..aa6c57f13b 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -130,7 +130,7 @@ def render(self) -> Text: label_text.stylize_before(self.rich_style) return label_text - async def on_mouse_down(self) -> None: + def on_mouse_down(self) -> None: if self._disabled: self.app.bell() else: diff --git a/tests/test_modal.py b/tests/test_modal.py index db45fcf584..e33fe29973 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -89,10 +89,11 @@ async def test_modal_pop_screen(): app = ModalApp() async with app.run_test() as pilot: # Pause to ensure the footer is fully composed to avoid flakiness in CI - await pilot.pause(0.4) + await pilot.pause() await app.wait_for_refresh() + await pilot.pause() # Check clicking the footer brings up the quit screen - await pilot.click(Footer) + await pilot.click(Footer, offset=(1, 0)) await pilot.pause() assert isinstance(pilot.app.screen, QuitScreen) # Check activating the quit button exits the app