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)