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 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownSpaceApp + + + + + + + + + + X XX XX X X X X X + + X XX XX X X X X X + + X XX X X X X X + + X XX X X X X X + + ─────────────────────────────────────────────────────────────────────────────── + + + # Two spaces:  see? + classFoo: + │   '''This is    a doc    string.''' + │   some_code(1,2,3,4) + + + + + + + + + + + + + + ''' +# --- # name: test_markdown_theme_switching ''' @@ -42458,6 +42626,168 @@ ''' # --- +# name: test_welcome + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WelcomeApp + + + + + + + + + + + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + Welcome! + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + Textual is a TUI, or Text User Interface, framework for Python inspired by   + modern web development. We hope you enjoy using Textual! + + + Dune quote + + ▌ "I must not fear.Fear is the mind-killer.Fear is the little-death that + ▌ brings total obliteration.I will face my fear.I will permit it to pass + ▌ over me and through me.And when it has gone past, I will turn the inner + ▌ eye to see its path.Where the fear has gone there will be nothing. Only + ▌ I will remain." + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + OK + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- # name: test_zero_scrollbar_size ''' diff --git a/tests/snapshot_tests/snapshot_apps/markdown_whitespace.py b/tests/snapshot_tests/snapshot_apps/markdown_whitespace.py new file mode 100644 index 0000000000..71c8fd8bbb --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/markdown_whitespace.py @@ -0,0 +1,59 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Markdown + +MARKDOWN = ( + """\ +X X + +X X + +X\tX + +X\t\tX +""", + """\ +X \tX + +X \t \tX +""", + """\ +[X X X\tX\t\tX \t \tX](https://example.com/) + +_X X X\tX\t\tX \t \tX_ + +**X X X\tX\t\tX \t \tX** + +~~X X X\tX\t\tX \t \tX~~ +""" +) + +class MarkdownSpaceApp(App[None]): + + CSS = """ + Markdown { + margin-left: 0; + border-left: solid red; + width: 1fr; + height: 1fr; + } + .code { + height: 2fr; + border-top: solid red; + } + """ + + def compose(self) -> ComposeResult: + with Horizontal(): + for document in MARKDOWN: + yield Markdown(document) + yield Markdown("""```python +# Two spaces: see? +class Foo: + '''This is a doc string.''' + some_code(1, 2, 3, 4) +``` +""", classes="code") + +if __name__ == "__main__": + MarkdownSpaceApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/welcome_widget.py b/tests/snapshot_tests/snapshot_apps/welcome_widget.py new file mode 100644 index 0000000000..b82287dcd6 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/welcome_widget.py @@ -0,0 +1,10 @@ +from textual.app import App, ComposeResult +from textual.widgets import Welcome + +class WelcomeApp(App[None]): + + def compose(self) -> ComposeResult: + yield Welcome() + +if __name__ == "__main__": + WelcomeApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 34a527d5e8..cf6ccb2fa3 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -686,6 +686,9 @@ async def run_before(pilot): run_before=run_before, ) +def test_markdown_space_squashing(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "markdown_whitespace.py") + def test_layer_fix(snap_compare): # Check https://github.com/Textualize/textual/issues/1358 @@ -1162,3 +1165,8 @@ def test_button_widths(snap_compare): """Test that button widths expand auto containers as expected.""" # https://github.com/Textualize/textual/issues/4024 assert snap_compare(SNAPSHOT_APPS_DIR / "button_widths.py") + + +def test_welcome(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "welcome_widget.py") +