From fec5571ee6b769e186819b25401fa8a84990f339 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 5 Oct 2025 11:27:25 +0100 Subject: [PATCH 1/5] scrollbar visibility rule --- src/textual/_compositor.py | 2 +- src/textual/css/_styles_builder.py | 9 +++++++++ src/textual/css/constants.py | 1 + src/textual/css/styles.py | 11 +++++++++-- src/textual/css/types.py | 1 + src/textual/dom.py | 2 -- src/textual/widget.py | 9 ++++++--- 7 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index a473c2e3e4..f60d4c9b83 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -694,7 +694,7 @@ def add_widget( if ( widget.show_vertical_scrollbar or widget.show_horizontal_scrollbar - ): + ) and styles.scrollbar_visibility == "visible": for chrome_widget, chrome_region in widget._arrange_scrollbars( container_region ): diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index c2234ecaae..95c157e858 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -52,6 +52,7 @@ VALID_OVERLAY, VALID_POSITION, VALID_SCROLLBAR_GUTTER, + VALID_SCROLLBAR_VISIBILITY, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, VALID_TEXT_OVERFLOW, @@ -76,6 +77,7 @@ Display, EdgeType, Overflow, + ScrollbarVisibility, TextOverflow, TextWrap, Visibility, @@ -768,6 +770,13 @@ def process_color(self, name: str, tokens: list[Token]) -> None: process_scrollbar_background_hover = process_color process_scrollbar_background_active = process_color + def process_scrollbar_visibility(self, name: str, tokens: list[Token]) -> None: + """Process scrollbar visibility rules.""" + self.styles._rules["scrollbar_visibility"] = cast( + ScrollbarVisibility, + self._process_enum(name, tokens, VALID_SCROLLBAR_VISIBILITY), + ) + process_link_color = process_color process_link_background = process_color process_link_color_hover = process_color diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 2145302306..46606d6709 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -90,6 +90,7 @@ VALID_TEXT_WRAP: Final = {"wrap", "nowrap"} VALID_TEXT_OVERFLOW: Final = {"clip", "fold", "ellipsis"} VALID_EXPAND: Final = {"greedy", "optimal"} +VALID_SCROLLBAR_VISIBILITY: Final = {"visible", "hidden"} HATCHES: Final = { "left": "╲", diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 0c2bed837f..e165d3590c 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -48,6 +48,7 @@ VALID_OVERLAY, VALID_POSITION, VALID_SCROLLBAR_GUTTER, + VALID_SCROLLBAR_VISIBILITY, VALID_TEXT_ALIGN, VALID_TEXT_OVERFLOW, VALID_TEXT_WRAP, @@ -153,11 +154,10 @@ class RulesMap(TypedDict, total=False): scrollbar_background: Color scrollbar_background_hover: Color scrollbar_background_active: Color - scrollbar_gutter: ScrollbarGutter - scrollbar_size_vertical: int scrollbar_size_horizontal: int + scrollbar_visibility: ScrollbarVisibility align_horizontal: AlignHorizontal align_vertical: AlignVertical @@ -242,6 +242,7 @@ class StylesBase: "scrollbar_background", "scrollbar_background_hover", "scrollbar_background_active", + "scrollbar_visibility", "link_color", "link_background", "link_color_hover", @@ -424,6 +425,10 @@ class StylesBase: """Set the width of the vertical scrollbar (measured in cells).""" scrollbar_size_horizontal = IntegerProperty(default=1, layout=True) """Set the height of the horizontal scrollbar (measured in cells).""" + scrollbar_visibility = StringEnumProperty( + VALID_SCROLLBAR_VISIBILITY, "visible", layout=True + ) + """Sets the visibility of the scrollbar.""" align_horizontal = StringEnumProperty( VALID_ALIGN_HORIZONTAL, "left", layout=True, refresh_children=True @@ -1153,6 +1158,8 @@ def append_declaration(name: str, value: str) -> None: append_declaration( "scrollbar-size-vertical", str(self.scrollbar_size_vertical) ) + if "scrollbar_visibility" in rules: + append_declaration("scrollbar-visibility", self.scrollbar_visibility) if "box_sizing" in rules: append_declaration("box-sizing", self.box_sizing) diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 4bf9e6671e..d2b2434808 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -44,6 +44,7 @@ TextWrap = Literal["wrap", "nowrap"] TextOverflow = Literal["clip", "fold", "ellipsis"] Expand = Literal["greedy", "expand"] +ScrollbarVisibility = Literal["visible", "hidden"] Specificity3 = Tuple[int, int, int] Specificity6 = Tuple[int, int, int, int, int, int] diff --git a/src/textual/dom.py b/src/textual/dom.py index 69ee6c8ba4..c0d80ee3b7 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1723,8 +1723,6 @@ def _update_styles(self) -> None: Should be called whenever CSS classes / pseudo classes change. """ - if not self.is_attached or not self.screen.is_mounted: - return try: self.app.update_styles(self) except NoActiveAppError: diff --git a/src/textual/widget.py b/src/textual/widget.py index 79a3a94d2e..f95fee6df1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -346,7 +346,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] = Reactive(Size(0, 0), layout=True, repaint=False) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" has_focus: Reactive[bool] = Reactive(False, repaint=False) @@ -3421,6 +3421,7 @@ def scroll_to_widget( `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 @@ -3430,7 +3431,11 @@ def scroll_to_widget( return False while isinstance(widget.parent, Widget) and widget is not self: + if not region: + break + container = widget.parent + if widget.styles.dock != "none": scroll_offset = Offset(0, 0) else: @@ -3454,13 +3459,11 @@ def scroll_to_widget( # Adjust the region by the amount we just scrolled it, and convert to # its parent's virtual coordinate system. - region = ( ( region.translate(-scroll_offset) .translate(container.styles.margin.top_left) .translate(container.styles.border.spacing.top_left) - .translate(-widget.scroll_offset) .translate(container.virtual_region_with_margin.offset) ) .grow(container.styles.margin) From de2b6d6c2c777fd640b5b85a1cd4b75d8f3e874c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 5 Oct 2025 11:28:48 +0100 Subject: [PATCH 2/5] repaint required --- src/textual/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index f95fee6df1..cdcd7103eb 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -346,7 +346,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, repaint=False) + virtual_size: Reactive[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) From 1e4ab382ea06b602e7e0979a387eb7e6faa57b48 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 5 Oct 2025 11:35:32 +0100 Subject: [PATCH 3/5] snapshot --- CHANGELOG.md | 6 + .../test_scrollbar_visibility.svg | 150 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 21 +++ 3 files changed, 177 insertions(+) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_scrollbar_visibility.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 5200430ceb..5e40a7bc8c 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/). +## Unreleased + +### Added + +- Added scrollbar-visibility rule https://github.com/Textualize/textual/pull/6156 + ## [6.2.1] - 2025-10-01 - Fix inability to copy text outside of an input/textarea when it was focused https://github.com/Textualize/textual/pull/6148 diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_scrollbar_visibility.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_scrollbar_visibility.svg new file mode 100644 index 0000000000..295e0aa7d1 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_scrollbar_visibility.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ScrollbarApp + + + + + + + + + + Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! +293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, +World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 +Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! +293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, +World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 +Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! +293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, +World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 +Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! +293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, +World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 +Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! +293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, +World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 +Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! +293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, +World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 +Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! +293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, +World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 +Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! +293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, +World! 293487 Hello, World! 293487 Hello, World! 293487 Hello, World! 293487 + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index ea8a7434f2..86cf17914a 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4690,3 +4690,24 @@ def compose(self) -> ComposeResult: yield Button("Hello") assert snap_compare(CollapseApp(), press=["enter", "enter", "tab"]) + + +def test_scrollbar_visibility(snap_compare) -> None: + """Test scrollbar-visibility rule + + You should see a screen of text that overflows, but there should be *no* scrollbar. + """ + + class ScrollbarApp(App): + + CSS = """ + Screen { + overflow: auto; + scrollbar-visibility: hidden; + } + """ + + def compose(self) -> ComposeResult: + yield Static("Hello, World! 293487 " * 200) + + assert snap_compare(ScrollbarApp()) From 88220b8f02c7bba69c9322654f6aecacb8ee6650 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 5 Oct 2025 11:57:18 +0100 Subject: [PATCH 4/5] docs --- docs/examples/styles/scrollbar_visibility.py | 27 +++++++++ .../examples/styles/scrollbar_visibility.tcss | 11 ++++ docs/styles/scrollbar_visibility.md | 60 +++++++++++++++++++ mkdocs-nav.yml | 1 + src/textual/widget.py | 1 - 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 docs/examples/styles/scrollbar_visibility.py create mode 100644 docs/examples/styles/scrollbar_visibility.tcss create mode 100644 docs/styles/scrollbar_visibility.md diff --git a/docs/examples/styles/scrollbar_visibility.py b/docs/examples/styles/scrollbar_visibility.py new file mode 100644 index 0000000000..3f08a8bf4a --- /dev/null +++ b/docs/examples/styles/scrollbar_visibility.py @@ -0,0 +1,27 @@ +from textual.app import App +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import Label + +TEXT = """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. +""" + + +class ScrollbarApp(App): + CSS_PATH = "scrollbar_visibility.tcss" + + def compose(self): + yield Horizontal( + VerticalScroll(Label(TEXT * 10), classes="left"), + VerticalScroll(Label(TEXT * 10), classes="right"), + ) + + +if __name__ == "__main__": + app = ScrollbarApp() + app.run() diff --git a/docs/examples/styles/scrollbar_visibility.tcss b/docs/examples/styles/scrollbar_visibility.tcss new file mode 100644 index 0000000000..cab96d982e --- /dev/null +++ b/docs/examples/styles/scrollbar_visibility.tcss @@ -0,0 +1,11 @@ +VerticalScroll { + width: 1fr; +} + +.left { + scrollbar-visibility: visible; # The default +} + +.right { + scrollbar-visibility: hidden; +} diff --git a/docs/styles/scrollbar_visibility.md b/docs/styles/scrollbar_visibility.md new file mode 100644 index 0000000000..c493597a7c --- /dev/null +++ b/docs/styles/scrollbar_visibility.md @@ -0,0 +1,60 @@ +# Scrollbar-visibility + +The `scrollbar-visibility` is used to show or hide scrollbars. + +If scrollbars are hidden, the user may still scroll the container using the mouse wheel / keys / and gestures, but +there will be no scrollbars shown. + +## Syntax + +--8<-- "docs/snippets/syntax_block_start.md" +scrollbar-visibility: hidden | visible; +--8<-- "docs/snippets/syntax_block_end.md" + + +### Values + +| Value | Description | +| ------------------- | ---------------------------------------------------- | +| `hidden` | The widget's scrollbars will be hidden. | +| `visible` (default) | The widget's scrollbars will be displayed as normal. | + + +## Examples + +The following example contains two containers with the same text. +The container on the right has its scrollbar hidden. + +=== "Output" + + ```{.textual path="docs/examples/styles/scrollbar_visibility.py"} + ``` + +=== "scrollbar_visibility.py" + + ```py + --8<-- "docs/examples/styles/scrollbar_visibility.py" + ``` + +=== "scrollbar_visibility.tcss" + + ```css + --8<-- "docs/examples/styles/scrollbar_visibility.tcss" + ``` + + +## CSS + +```css +scrollbar-visibility: visible; +scrollbar-visibility: hidden; +``` + + + +## Python + +```py +widget.styles.scrollbar_visibility = "visible"; +widget.styles.scrollbar_visibility = "hidden"; +``` diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index a587d54c82..e6b7b66725 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -136,6 +136,7 @@ nav: - "styles/scrollbar_colors/scrollbar_corner_color.md" - "styles/scrollbar_gutter.md" - "styles/scrollbar_size.md" + - "styles/scrollbar_visibility.md" - "styles/text_align.md" - "styles/text_opacity.md" - "styles/text_overflow.md" diff --git a/src/textual/widget.py b/src/textual/widget.py index cdcd7103eb..cf56e9536d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3421,7 +3421,6 @@ def scroll_to_widget( `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 From 0198b6363d00fb65578e1b77ef307b37fe638b9c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 5 Oct 2025 12:07:02 +0100 Subject: [PATCH 5/5] snapshot --- ..._css_property[scrollbar_visibility.py].svg | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[scrollbar_visibility.py].svg diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[scrollbar_visibility.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[scrollbar_visibility.py].svg new file mode 100644 index 0000000000..5cd29a1d07 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[scrollbar_visibility.py].svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ScrollbarApp + + + + + + + + + + I must not fear.I must not fear. +Fear is the mind-killer.Fear is the mind-killer. +Fear is the little-death that brings tFear is the little-death that brings t +I will face my fear.I will face my fear. +I will permit it to pass over me and tI will permit it to pass over me and t +And when it has gone past, I will turnAnd when it has gone past, I will turn +Where the fear has gone there will be Where the fear has gone there will be  +I must not fear.I must not fear. +Fear is the mind-killer.▇▇Fear is the mind-killer. +Fear is the little-death that brings tFear is the little-death that brings t +I will face my fear.I will face my fear. +I will permit it to pass over me and tI will permit it to pass over me and t +And when it has gone past, I will turnAnd when it has gone past, I will turn +Where the fear has gone there will be Where the fear has gone there will be  +I must not fear.I must not fear. +Fear is the mind-killer.Fear is the mind-killer. +Fear is the little-death that brings tFear is the little-death that brings t +I will face my fear.I will face my fear. +I will permit it to pass over me and tI will permit it to pass over me and t +And when it has gone past, I will turnAnd when it has gone past, I will turn +Where the fear has gone there will be Where the fear has gone there will be  +I must not fear.I must not fear. +Fear is the mind-killer.Fear is the mind-killer. +Fear is the little-death that brings tFear is the little-death that brings t + + +