diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c2d653e06..49d8b9e2e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Fixed a crash in `TextArea` when undoing an edit to a selection the selection was made backwards https://github.com/Textualize/textual/issues/4301
+- Fixed issue with flickering scrollbars https://github.com/Textualize/textual/pull/4315
+- Fixed issue where narrow TextArea would repeatedly wrap due to scrollbar appearing/disappearing https://github.com/Textualize/textual/pull/4334
- Fix progress bar ETA not updating when setting `total` reactive https://github.com/Textualize/textual/pull/4316
+
+### Changed
+
- ProgressBar won't show ETA until there is at least one second of samples https://github.com/Textualize/textual/pull/4316
## [0.53.1] - 2023-03-18
diff --git a/src/textual/screen.py b/src/textual/screen.py
index d29022d3a5..b9713e7256 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -38,6 +38,7 @@
from .css.parse import parse_selectors
from .css.query import NoMatches, QueryType
from .dom import DOMNode
+from .errors import NoWidget
from .geometry import Offset, Region, Size
from .reactive import Reactive
from .renderables.background_screen import BackgroundScreen
@@ -54,7 +55,6 @@
from .command import Provider
# Unused & ignored imports are needed for the docs to link to these objects:
- from .errors import NoWidget # type: ignore # noqa: F401
from .message_pump import MessagePump
# Screen updates will be batched so that they don't happen more often than 60 times per second:
diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py
index 4e418ed350..88493f3ed1 100644
--- a/src/textual/scroll_view.py
+++ b/src/textual/scroll_view.py
@@ -89,6 +89,7 @@ def _size_updated(
or virtual_size != self.virtual_size
or container_size != self.container_size
):
+ self._scrollbar_changes.clear()
self._size = size
virtual_size = self.virtual_size
self._container_size = size - self.styles.gutter.totals
diff --git a/src/textual/signal.py b/src/textual/signal.py
index 6226b0273b..a1a1d80b8a 100644
--- a/src/textual/signal.py
+++ b/src/textual/signal.py
@@ -88,4 +88,6 @@ def publish(self) -> None:
try:
callback()
except Exception as error:
- log.error(f"error publishing signal to {node} ignored; {error}")
+ log.error(
+ f"error publishing signal to {node} ignored (callback={callback}); {error}"
+ )
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 4a27762f7d..aac76e8778 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -361,14 +361,15 @@ def __init__(
self._styles_cache = StylesCache()
self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
- self._stabilize_scrollbar: tuple[Size, str, str] | None = None
- """Used to prevent scrollbar logic getting stuck in an infinite loop."""
self._tooltip: RenderableType | None = None
"""The tooltip content."""
self._absolute_offset: Offset | None = None
"""Force an absolute offset for the widget (used by tooltips)."""
+ self._scrollbar_changes: set[tuple[bool, bool]] = set()
+ """Used to stabilize scrollbars."""
+
super().__init__(
name=name,
id=id,
@@ -799,7 +800,6 @@ def _arrange(self, size: Size) -> DockArrangeResult:
def _clear_arrangement_cache(self) -> None:
"""Clear arrangement cache, forcing a new arrange operation."""
self._arrangement_cache.clear()
- self._stabilize_scrollbar = None
def _get_virtual_dom(self) -> Iterable[Widget]:
"""Get widgets not part of the DOM.
@@ -1437,14 +1437,6 @@ def _refresh_scrollbars(self) -> None:
overflow_x = styles.overflow_x
overflow_y = styles.overflow_y
- stabilize_scrollbar = (
- self.container_size,
- overflow_x,
- overflow_y,
- )
- if self._stabilize_scrollbar == stabilize_scrollbar:
- return
-
width, height = self._container_size
show_horizontal = False
@@ -1463,17 +1455,31 @@ def _refresh_scrollbars(self) -> None:
elif overflow_y == "auto":
show_vertical = self.virtual_size.height > height
- # When a single scrollbar is shown, the other dimension changes, so we need to recalculate.
- if overflow_x == "auto" and show_vertical and not show_horizontal:
- show_horizontal = self.virtual_size.width > (
- width - styles.scrollbar_size_vertical
- )
- if overflow_y == "auto" and show_horizontal and not show_vertical:
- show_vertical = self.virtual_size.height > (
- height - styles.scrollbar_size_horizontal
- )
+ _show_horizontal = show_horizontal
+ _show_vertical = show_vertical
- self._stabilize_scrollbar = stabilize_scrollbar
+ if not (
+ overflow_x == "auto"
+ and overflow_y == "auto"
+ and (show_horizontal, show_vertical) in self._scrollbar_changes
+ ):
+ # When a single scrollbar is shown, the other dimension changes, so we need to recalculate.
+ if overflow_x == "auto" and show_vertical and not show_horizontal:
+ show_horizontal = self.virtual_size.width > (
+ width - styles.scrollbar_size_vertical
+ )
+ if overflow_y == "auto" and show_horizontal and not show_vertical:
+ show_vertical = self.virtual_size.height > (
+ height - styles.scrollbar_size_horizontal
+ )
+
+ if (
+ self.show_horizontal_scrollbar != show_horizontal
+ or self.show_vertical_scrollbar != show_vertical
+ ):
+ self._scrollbar_changes.add((_show_horizontal, _show_vertical))
+ else:
+ self._scrollbar_changes.clear()
self.show_horizontal_scrollbar = show_horizontal
self.show_vertical_scrollbar = show_vertical
@@ -3322,7 +3328,6 @@ def refresh(
return self
if layout:
self._layout_required = True
- self._stabilize_scrollbar = None
for ancestor in self.ancestors:
if not isinstance(ancestor, Widget):
break
diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py
index c37c7770e0..849e291bd4 100644
--- a/src/textual/widgets/_markdown.py
+++ b/src/textual/widgets/_markdown.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import re
from pathlib import Path, PurePath
from typing import Callable, Iterable, Optional
@@ -149,7 +150,12 @@ def build_from_token(self, token: Token) -> None:
if token.children:
for child in token.children:
if child.type == "text":
- content.append(child.content, style_stack[-1])
+ content.append(
+ # Ensure repeating spaces and/or tabs get squashed
+ # down to a single space.
+ re.sub(r"\s+", " ", child.content),
+ style_stack[-1],
+ )
if child.type == "hardbreak":
content.append("\n")
if child.type == "softbreak":
diff --git a/src/textual/widgets/_sparkline.py b/src/textual/widgets/_sparkline.py
index f3315ba153..d691f5f435 100644
--- a/src/textual/widgets/_sparkline.py
+++ b/src/textual/widgets/_sparkline.py
@@ -66,7 +66,7 @@ def __init__(
Args:
data: The initial data to populate the sparkline with.
- summary_function: Summarises bar values into a single value used to
+ summary_function: Summarizes bar values into a single value used to
represent each bar.
name: The name of the widget.
id: The ID of the widget in the DOM.
diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py
index 501eef8f40..71e1b3fd7c 100644
--- a/src/textual/widgets/_text_area.py
+++ b/src/textual/widgets/_text_area.py
@@ -688,7 +688,8 @@ def _watch_indent_width(self) -> None:
self.scroll_cursor_visible()
def _watch_show_vertical_scrollbar(self) -> None:
- self._rewrap_and_refresh_virtual_size()
+ if self.wrap_width:
+ self._rewrap_and_refresh_virtual_size()
self.scroll_cursor_visible()
def _watch_theme(self, theme: str) -> None:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 263202293d..c375135d8b 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -23647,6 +23647,174 @@
'''
# ---
+# name: test_markdown_space_squashing
+ '''
+
+
+ '''
+# ---
# name: test_markdown_theme_switching
'''