From efbb5ce2862c56a23c4e057a1c62db5c7a9def42 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Jan 2026 21:36:18 +0000 Subject: [PATCH 1/8] auto links optimization --- src/textual/css/stylesheet.py | 15 ++++++++++++--- src/textual/reactive.py | 1 + src/textual/scrollbar.py | 5 ++++- src/textual/visual.py | 4 +++- src/textual/widget.py | 26 ++++++++++++++++++-------- src/textual/widgets/_footer.py | 5 +++-- src/textual/widgets/_static.py | 4 ++-- 7 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 7d97cda9f0..019ec062bf 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -723,9 +723,18 @@ def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: for node in nodes: apply(node, animate=animate, cache=cache) if isinstance(node, Widget) and node.is_scrollable: - if node.show_vertical_scrollbar: + scrollbar_size_vertical, scrollbar_size_horizontal = ( + node.scrollbars_space + ) + show_vertical_scrollbar = ( + node.show_vertical_scrollbar and scrollbar_size_vertical + ) + show_horizontal_scrollbar = ( + node.show_horizontal_scrollbar and scrollbar_size_horizontal + ) + if show_vertical_scrollbar: apply(node.vertical_scrollbar, cache=cache) - if node.show_horizontal_scrollbar: + if show_horizontal_scrollbar: apply(node.horizontal_scrollbar, cache=cache) - if node.show_horizontal_scrollbar and node.show_vertical_scrollbar: + if show_horizontal_scrollbar and show_vertical_scrollbar: apply(node.scrollbar_corner, cache=cache) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 57c0bf3ea1..1ff091aec4 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -349,6 +349,7 @@ def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> Non # If the value has changed, or this is the first time setting the value if always or self._always_update or current_value != value: # Store the internal value + setattr(obj, self.internal_name, value) # Check all watchers diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index ef039148c5..69c0028044 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -258,7 +258,7 @@ def __init__( self.thickness = thickness self.grabbed_position: float = 0 super().__init__(name=name) - self.auto_links = False + self.set_reactive(ScrollBar.auto_links, False) window_virtual_size: Reactive[int] = Reactive(100) window_size: Reactive[int] = Reactive(0) @@ -274,6 +274,9 @@ def __rich_repr__(self) -> rich.repr.Result: if self.thickness > 1: yield "thickness", self.thickness + def validate_position(self, position: float) -> float: + return int(position * 8) / 8 + def render(self) -> RenderableType: assert self.parent is not None styles = self.parent.styles diff --git a/src/textual/visual.py b/src/textual/visual.py index 0cb4d2de72..e10dcaab53 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -235,7 +235,9 @@ def to_strips( selection_style, ), ) - strips = [strip._apply_link_style(widget.link_style) for strip in strips] + if widget.auto_links and not widget.is_container: + link_style = widget.link_style + strips = [strip._apply_link_style(link_style) for strip in strips] if height is None: height = len(strips) diff --git a/src/textual/widget.py b/src/textual/widget.py index 38cfb6ae5f..ba2b4462e1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -349,7 +349,7 @@ class Widget(DOMNode): loading: Reactive[bool] = Reactive(False) """If set to `True` this widget will temporarily be replaced with a loading indicator.""" - virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True) + virtual_size = Reactive(Size(0, 0), repaint=False) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" has_focus: Reactive[bool] = Reactive(False, repaint=False) @@ -370,10 +370,14 @@ class Widget(DOMNode): scroll_target_y = Reactive(0.0, repaint=False) """Scroll target destination, Y coord.""" - show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True) + show_vertical_scrollbar: Reactive[bool] = Reactive( + False, layout=False, repaint=False + ) """Show a vertical scrollbar?""" - show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True) + show_horizontal_scrollbar: Reactive[bool] = Reactive( + False, layout=False, repaint=False + ) """Show a horizontal scrollbar?""" border_title = _BorderTitle() # type: ignore @@ -1894,6 +1898,9 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int return height + def watch_virtual_size(self, virtual_size: Size) -> None: + self.refresh(layout=True) + def watch_hover_style( self, previous_hover_style: Style, hover_style: Style ) -> None: @@ -1903,12 +1910,14 @@ def watch_hover_style( self.highlight_link_id = hover_style.link_id def watch_scroll_x(self, old_value: float, new_value: float) -> None: - self.horizontal_scrollbar.position = new_value + if self.show_horizontal_scrollbar: + self.horizontal_scrollbar.position = new_value if round(old_value) != round(new_value): self._refresh_scroll() def watch_scroll_y(self, old_value: float, new_value: float) -> None: - self.vertical_scrollbar.position = new_value + if self.show_vertical_scrollbar: + self.vertical_scrollbar.position = new_value if self._anchored and self._anchor_released: self._check_anchor() if round(old_value) != round(new_value): @@ -2644,7 +2653,8 @@ def _set_dirty(self, *regions: Region) -> None: self._dirty_regions.clear() self._repaint_regions.clear() self._styles_cache.clear() - self._styles_cache.set_dirty(self.size.region) + + # self._styles_cache.set_dirty(self.size.region) outer_size = self.outer_size self._dirty_regions.add(outer_size.region) @@ -4092,13 +4102,13 @@ def _scroll_update(self, virtual_size: Size) -> None: self._refresh_scrollbars() width, height = self.container_size - if self.show_vertical_scrollbar: + if self.show_vertical_scrollbar and self.styles.scrollbar_size_vertical: self.vertical_scrollbar.window_virtual_size = virtual_size.height self.vertical_scrollbar.window_size = ( height - self.scrollbar_size_horizontal ) self.vertical_scrollbar.refresh() - if self.show_horizontal_scrollbar: + if self.show_horizontal_scrollbar and self.styles.scrollbar_size_horizontal: self.horizontal_scrollbar.window_virtual_size = virtual_size.width self.horizontal_scrollbar.window_size = width - self.scrollbar_size_vertical self.horizontal_scrollbar.refresh() diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 14aa56cb81..cf642f9157 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -96,7 +96,7 @@ def __init__( if disabled: classes += " -disabled" super().__init__(classes=classes) - self.shrink = False + self.set_reactive(Widget.shrink, False) if tooltip: self.tooltip = tooltip @@ -260,7 +260,8 @@ def __init__( disabled=disabled, ) self.set_reactive(Footer.show_command_palette, show_command_palette) - self.compact = compact + self.set_reactive(Footer.compact, compact) + self.set_class(compact, "-compact") def compose(self) -> ComposeResult: if not self._bindings_ready: diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index cd2f443ab2..389171400e 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -44,8 +44,8 @@ def __init__( super().__init__( name=name, id=id, classes=classes, disabled=disabled, markup=markup ) - self.expand = expand - self.shrink = shrink + self.set_reactive(Widget.expand, expand) + self.set_reactive(Widget.shrink, shrink) self.__content = content self.__visual: Visual | None = None From 1d91e6aa12fe5ce713249c67a16ae4792f21251f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Jan 2026 21:38:34 +0000 Subject: [PATCH 2/8] whitespace --- src/textual/reactive.py | 1 - src/textual/widget.py | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 1ff091aec4..57c0bf3ea1 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -349,7 +349,6 @@ def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> Non # If the value has changed, or this is the first time setting the value if always or self._always_update or current_value != value: # Store the internal value - setattr(obj, self.internal_name, value) # Check all watchers diff --git a/src/textual/widget.py b/src/textual/widget.py index ba2b4462e1..c5d911b43c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2653,9 +2653,6 @@ def _set_dirty(self, *regions: Region) -> None: self._dirty_regions.clear() self._repaint_regions.clear() self._styles_cache.clear() - - # self._styles_cache.set_dirty(self.size.region) - outer_size = self.outer_size self._dirty_regions.add(outer_size.region) if outer_size: From 1e90c2fd73adb69cf07ec985f32374113eaedafc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Jan 2026 21:40:27 +0000 Subject: [PATCH 3/8] restore virtual size layout --- src/textual/widget.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index c5d911b43c..8796f76245 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -349,7 +349,7 @@ class Widget(DOMNode): loading: Reactive[bool] = Reactive(False) """If set to `True` this widget will temporarily be replaced with a loading indicator.""" - virtual_size = Reactive(Size(0, 0), repaint=False) + virtual_size = Reactive(Size(0, 0), layout=True) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" has_focus: Reactive[bool] = Reactive(False, repaint=False) @@ -1898,9 +1898,6 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int return height - def watch_virtual_size(self, virtual_size: Size) -> None: - self.refresh(layout=True) - def watch_hover_style( self, previous_hover_style: Style, hover_style: Style ) -> None: From 7f8d3cb532ccc59f794fa407c8707294f2c7f90c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Jan 2026 21:44:47 +0000 Subject: [PATCH 4/8] don't update on set_class --- src/textual/scrollbar.py | 1 + src/textual/widgets/_footer.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 69c0028044..41c90d59cb 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -275,6 +275,7 @@ def __rich_repr__(self) -> rich.repr.Result: yield "thickness", self.thickness def validate_position(self, position: float) -> float: + """Position has a granulatory of 1/8 of a cell.""" return int(position * 8) / 8 def render(self) -> RenderableType: diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index cf642f9157..4bbbfd88d6 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -261,7 +261,7 @@ def __init__( ) self.set_reactive(Footer.show_command_palette, show_command_palette) self.set_reactive(Footer.compact, compact) - self.set_class(compact, "-compact") + self.set_class(compact, "-compact", update=False) def compose(self) -> ComposeResult: if not self._bindings_ready: From 226a94a03b51b0ecc6398fa0a725f73e8a57d9fa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Jan 2026 21:46:26 +0000 Subject: [PATCH 5/8] comment --- src/textual/visual.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/visual.py b/src/textual/visual.py index e10dcaab53..4f9f6ea5d7 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -236,6 +236,7 @@ def to_strips( ), ) if widget.auto_links and not widget.is_container: + # TODO: This is suprisingly expensive (why?) link_style = widget.link_style strips = [strip._apply_link_style(link_style) for strip in strips] From 4d8a905502d06b6a17577c0fc69b0153be6745f0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Jan 2026 21:48:16 +0000 Subject: [PATCH 6/8] bump --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a25a18fd..c04c4250c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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/). +## [7.0.3] - 2026-01-10 + +### Fixed + +- Fixed performance issue with large scrollable containers https://github.com/Textualize/textual/pull/6317 + ## [7.0.2] - 2026-01-09 ### Fixed @@ -3284,6 +3290,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 +[7.0.3]: https://github.com/Textualize/textual/compare/v7.0.2...v7.0.3 [7.0.2]: https://github.com/Textualize/textual/compare/v7.0.1...v7.0.2 [7.0.1]: https://github.com/Textualize/textual/compare/v7.0.0...v7.0.1 [7.0.0]: https://github.com/Textualize/textual/compare/v6.11.0...v7.0.0 diff --git a/pyproject.toml b/pyproject.toml index 17206a8953..dc28faf4ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "7.0.2" +version = "7.0.3" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From a1f2f8b4e08374d7dd4bfea9754135ae03b7d03e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Jan 2026 21:55:30 +0000 Subject: [PATCH 7/8] restore scrollbar behavor --- src/textual/widget.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 8796f76245..94b254b520 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -370,14 +370,10 @@ class Widget(DOMNode): scroll_target_y = Reactive(0.0, repaint=False) """Scroll target destination, Y coord.""" - show_vertical_scrollbar: Reactive[bool] = Reactive( - False, layout=False, repaint=False - ) + show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True) """Show a vertical scrollbar?""" - show_horizontal_scrollbar: Reactive[bool] = Reactive( - False, layout=False, repaint=False - ) + show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True) """Show a horizontal scrollbar?""" border_title = _BorderTitle() # type: ignore From 8f8b883c5d5456e35ebcbe3059de2ec2955f13d2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Jan 2026 22:10:23 +0000 Subject: [PATCH 8/8] simplify --- src/textual/css/stylesheet.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 019ec062bf..34387071f4 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -723,14 +723,11 @@ def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: for node in nodes: apply(node, animate=animate, cache=cache) if isinstance(node, Widget) and node.is_scrollable: - scrollbar_size_vertical, scrollbar_size_horizontal = ( - node.scrollbars_space - ) show_vertical_scrollbar = ( - node.show_vertical_scrollbar and scrollbar_size_vertical + node.show_vertical_scrollbar and node.scrollbar_size_vertical ) show_horizontal_scrollbar = ( - node.show_horizontal_scrollbar and scrollbar_size_horizontal + node.show_horizontal_scrollbar and node.scrollbar_size_horizontal ) if show_vertical_scrollbar: apply(node.vertical_scrollbar, cache=cache)