diff --git a/CHANGELOG.md b/CHANGELOG.md index 931d541c0c..dfebf364e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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/). + +## [0.58.1] - 2024-05-01 + +### Fixed + +- Fixed issue with Markdown mounting content lazily https://github.com/Textualize/textual/pull/4466 +- Fixed intermittent issue with scrolling to focus https://github.com/Textualize/textual/commit/567caf8acb196260adf6a0a6250e3ff5093056d0 +- Fixed issue with scrolling to center https://github.com/Textualize/textual/pull/4469 + ## [0.58.0] - 2024-04-25 ### Fixed @@ -1908,6 +1917,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.58.1]: https://github.com/Textualize/textual/compare/v0.58.0...v0.58.1 [0.58.0]: https://github.com/Textualize/textual/compare/v0.57.1...v0.58.0 [0.57.1]: https://github.com/Textualize/textual/compare/v0.57.0...v0.57.1 [0.57.0]: https://github.com/Textualize/textual/compare/v0.56.3...v0.57.0 diff --git a/pyproject.toml b/pyproject.toml index cc2b03cfdf..370595b7f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.58.0" +version = "0.58.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" diff --git a/src/textual/app.py b/src/textual/app.py index 637a049af1..ae4dab8400 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2845,7 +2845,7 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None: try: try: if isinstance(renderable, CompositorUpdate): - cursor_position = self.screen.size.clamp_offset( + cursor_position = self.screen.outer_size.clamp_offset( self.cursor_position ) if self._driver.is_inline: @@ -3341,7 +3341,10 @@ def _watch_app_focus(self, focus: bool) -> None: and self.screen.focused is None ): # ...settle focus back on that widget. - self.screen.set_focus(self._last_focused_on_app_blur) + # Don't scroll the newly focused widget, as this can be quite jarring + self.screen.set_focus( + self._last_focused_on_app_blur, scroll_visible=False + ) except NoScreen: pass # Now that we have focus back on the app and we don't need the diff --git a/src/textual/geometry.py b/src/textual/geometry.py index c8b7ab99da..3d21f17719 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -162,7 +162,7 @@ def clamp(self, width: int, height: int) -> Offset: A new offset. """ x, y = self - return Offset(clamp(x, 0, width), clamp(y, 0, height)) + return Offset(clamp(x, 0, width - 1), clamp(y, 0, height - 1)) class Size(NamedTuple): diff --git a/src/textual/screen.py b/src/textual/screen.py index b84fbc277a..799b945981 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -628,17 +628,17 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: # Change focus self.focused = widget # Send focus event + widget.post_message(events.Focus()) + focused = widget + if scroll_visible: def scroll_to_center(widget: Widget) -> None: """Scroll to center (after a refresh).""" - if widget.has_focus and not self.screen.can_view(widget): - self.screen.scroll_to_center(widget, origin_visible=True) + if self.focused is widget and not self.can_view(widget): + self.scroll_to_center(widget, origin_visible=True) - self.call_after_refresh(scroll_to_center, widget) - - widget.post_message(events.Focus()) - focused = widget + self.call_later(scroll_to_center, widget) self.log.debug(widget, "was focused") diff --git a/src/textual/widget.py b/src/textual/widget.py index b5b96f417e..33c143095c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2664,7 +2664,6 @@ def scroll_to_widget( Returns: `True` if any scrolling has occurred in any descendant, otherwise `False`. """ - # Grow the region by the margin so to keep the margin in view. region = widget.virtual_region_with_margin scrolled = False @@ -2676,7 +2675,7 @@ def scroll_to_widget( else: scroll_offset = container.scroll_to_region( region, - spacing=widget.gutter + widget.dock_gutter, + spacing=widget.dock_gutter, animate=animate, speed=speed, duration=duration, @@ -2751,29 +2750,40 @@ def scroll_to_region( if window in region and not (top or center): return Offset() + def clamp_delta(delta: Offset) -> Offset: + """Clamp the delta to avoid scrolling out of range.""" + scroll_x, scroll_y = self.scroll_offset + delta = Offset( + clamp(scroll_x + delta.x, 0, self.max_scroll_x) - scroll_x, + clamp(scroll_y + delta.y, 0, self.max_scroll_y) - scroll_y, + ) + return delta + if center: region_center_x, region_center_y = region.center window_center_x, window_center_y = window.center - center_delta = Offset( - round(region_center_x - window_center_x), - round(region_center_y - window_center_y), + + delta = clamp_delta( + Offset( + int(region_center_x - window_center_x + 0.5), + int(region_center_y - window_center_y + 0.5), + ) ) - if origin_visible and region.offset not in window.translate(center_delta): - center_delta = Region.get_scroll_to_visible(window, region, top=True) - delta_x, delta_y = center_delta + if origin_visible and (region.offset not in window.translate(delta)): + delta = clamp_delta( + Region.get_scroll_to_visible(window, region, top=True) + ) else: - delta_x, delta_y = Region.get_scroll_to_visible(window, region, top=top) - scroll_x, scroll_y = self.scroll_offset + delta = clamp_delta( + Region.get_scroll_to_visible(window, region, top=top), + ) if not self.allow_horizontal_scroll and not force: - delta_x = 0 + delta = Offset(0, delta.y) + if not self.allow_vertical_scroll and not force: - delta_y = 0 + delta = Offset(delta.x, 0) - delta = Offset( - clamp(scroll_x + delta_x, 0, self.max_scroll_x) - scroll_x, - clamp(scroll_y + delta_y, 0, self.max_scroll_y) - scroll_y, - ) if delta: if speed is None and duration is None: duration = 0.2 @@ -2857,7 +2867,6 @@ def scroll_to_center( on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). """ - self.call_after_refresh( self.scroll_to_widget, widget=widget, diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 532076c9f8..551a9d4816 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -253,10 +253,13 @@ async def _on_click(self, event: events.Click) -> None: self.press() def press(self) -> Self: - """Respond to a button press. + """Animate the button and send the [Pressed][textual.widgets.button.Button.Pressed] message. + + Can be used to simulate the button being pressed by a user. Returns: - The button instance.""" + The button instance. + """ if self.disabled or not self.display: return self # Manage the "active" effect: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 849e291bd4..6a05259642 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -540,7 +540,7 @@ class MarkdownBullet(Widget): } """ - symbol = reactive("\u25CF") + symbol = reactive("\u25cf") """The symbol for the bullet.""" def render(self) -> Text: @@ -681,7 +681,7 @@ class Markdown(Widget): | `strong` | Target text that is styled inline with strong. | """ - BULLETS = ["\u25CF ", "▪ ", "‣ ", "• ", "⭑ "] + BULLETS = ["\u25cf ", "▪ ", "‣ ", "• ", "⭑ "] code_dark_theme: reactive[str] = reactive("material") """The theme to use for code blocks when in [dark mode][textual.app.App.dark].""" @@ -771,9 +771,9 @@ def control(self) -> Markdown: """ return self.markdown - def _on_mount(self, _: Mount) -> None: + async def _on_mount(self, _: Mount) -> None: if self._markdown is not None: - self.update(self._markdown) + await self.update(self._markdown) def _watch_code_dark_theme(self) -> None: """React to the dark theme being changed.""" diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 5b7e08df7c..338bd6336a 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -33772,142 +33772,142 @@ font-weight: 700; } - .terminal-2006637091-matrix { + .terminal-3921271335-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2006637091-title { + .terminal-3921271335-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2006637091-r1 { fill: #1e1e1e } - .terminal-2006637091-r2 { fill: #c5c8c6 } - .terminal-2006637091-r3 { fill: #434343 } - .terminal-2006637091-r4 { fill: #262626;font-weight: bold } - .terminal-2006637091-r5 { fill: #e2e2e2 } - .terminal-2006637091-r6 { fill: #e1e1e1 } - .terminal-2006637091-r7 { fill: #23568b } - .terminal-2006637091-r8 { fill: #14191f } - .terminal-2006637091-r9 { fill: #ddedf9 } + .terminal-3921271335-r1 { fill: #1e1e1e } + .terminal-3921271335-r2 { fill: #e1e1e1 } + .terminal-3921271335-r3 { fill: #c5c8c6 } + .terminal-3921271335-r4 { fill: #434343 } + .terminal-3921271335-r5 { fill: #262626;font-weight: bold } + .terminal-3921271335-r6 { fill: #e2e2e2 } + .terminal-3921271335-r7 { fill: #23568b } + .terminal-3921271335-r8 { fill: #14191f } + .terminal-3921271335-r9 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollOffByOne + ScrollOffByOne - - - - X 43 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 44 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 45 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 46 - ▁▁▁▁▁▁▁▁▃▃ - ▔▔▔▔▔▔▔▔ - X 47▂▂ - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 48 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 49 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 50 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ + + + + ▔▔▔▔▔▔▔▔ + X 43 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 44 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 45 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 46▄▄ + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▃▃ + X 47 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 48 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 49 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 50 + ▁▁▁▁▁▁▁▁ @@ -33938,137 +33938,137 @@ font-weight: 700; } - .terminal-1340160965-matrix { + .terminal-857929372-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1340160965-title { + .terminal-857929372-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1340160965-r1 { fill: #e1e1e1 } - .terminal-1340160965-r2 { fill: #c5c8c6 } - .terminal-1340160965-r3 { fill: #004578 } - .terminal-1340160965-r4 { fill: #23568b } - .terminal-1340160965-r5 { fill: #fea62b } - .terminal-1340160965-r6 { fill: #f4005f } - .terminal-1340160965-r7 { fill: #14191f } + .terminal-857929372-r1 { fill: #e1e1e1 } + .terminal-857929372-r2 { fill: #c5c8c6 } + .terminal-857929372-r3 { fill: #004578 } + .terminal-857929372-r4 { fill: #23568b } + .terminal-857929372-r5 { fill: #fea62b } + .terminal-857929372-r6 { fill: #f4005f } + .terminal-857929372-r7 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - - - - SPAM - SPAM - SPAM - ──────────────────────────────────────────────────────────────────────────── - SPAM - SPAM - SPAM - SPAM - SPAM - SPAM▄▄ - SPAM - SPAM - ──────────────────────────────────────────────────────────────────────── - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - ▇▇ - ▄▄ - - - - - - - - ──────────────────────────────────────────────────────────────────────────── + + + + SPAM + SPAM + ──────────────────────────────────────────────────────────────────────────── + SPAM + SPAM + SPAM + SPAM + SPAM + SPAM + SPAM▂▂ + SPAM + SPAM▁▁ + ──────────────────────────────────────────────────────────────────────── + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + ▅▅▅▅ + + + + + + + + ──────────────────────────────────────────────────────────────────────────── + SPAM diff --git a/tests/snapshot_tests/snapshot_apps/scroll_to_center.py b/tests/snapshot_tests/snapshot_apps/scroll_to_center.py index 7a81a9b7f1..b4ec68881a 100644 --- a/tests/snapshot_tests/snapshot_apps/scroll_to_center.py +++ b/tests/snapshot_tests/snapshot_apps/scroll_to_center.py @@ -35,7 +35,7 @@ def compose(self) -> ComposeResult: yield Label(("SPAM\n" * 51)[:-1]) def key_s(self) -> None: - self.screen.scroll_to_center(self.query_one("#bullseye")) + self.screen.scroll_to_center(self.query_one("#bullseye"), origin_visible=False) if __name__ == "__main__": diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 021b216369..acb06f57b8 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -486,3 +486,17 @@ def test_size_with_height(): def test_size_with_width(): """Test Size.with_width""" assert Size(1, 2).with_width(10) == Size(10, 2) + + +def test_offset_clamp(): + assert Offset(1, 2).clamp(3, 3) == Offset(1, 2) + assert Offset(3, 2).clamp(3, 3) == Offset(2, 2) + assert Offset(-3, 2).clamp(3, 3) == Offset(0, 2) + assert Offset(5, 4).clamp(3, 3) == Offset(2, 2) + + +def test_size_clamp_offset(): + assert Size(3, 3).clamp_offset(Offset(1, 2)) == Offset(1, 2) + assert Size(3, 3).clamp_offset(Offset(3, 2)) == Offset(2, 2) + assert Size(3, 3).clamp_offset(Offset(-3, 2)) == Offset(0, 2) + assert Size(3, 3).clamp_offset(Offset(5, 4)) == Offset(2, 2)