Repository: Textualize/textual

Issue:
Scrollbar gutter
We should add a scrollbar gutter to CSS.

https://twitter.com/stackblitz/status/1518924375721873408?s=20&t=xu6iJ6y1WYPbxf_ck9KCoQ

Discussion:

Patch:
diff --git a/sandbox/basic.css b/sandbox/basic.css
index f9bc0b96d..a8dd22a17 100644
--- a/sandbox/basic.css
+++ b/sandbox/basic.css
@@ -85,7 +85,8 @@ Tweet {
   /* border: outer $primary; */
   padding: 1;
   border: wide $panel-darken-2;
-  overflow-y: scroll; 
+  overflow-y: auto;
+  scrollbar-gutter: stable;
   align-horizontal: center;
   
 }
@@ -117,7 +118,7 @@ TweetBody {
   width: 100%;
   background: $panel;
   color: $text-panel;
-  height:20;
+  height: auto;
   padding: 0 1 0 0;
  
 }
diff --git a/sandbox/basic.py b/sandbox/basic.py
index 7e9f78c28..57c572027 100644
--- a/sandbox/basic.py
+++ b/sandbox/basic.py
@@ -4,6 +4,7 @@ from rich.syntax import Syntax
 from rich.text import Text
 
 from textual.app import App
+from textual.reactive import Reactive
 from textual.widget import Widget
 from textual.widgets import Static
 
@@ -44,11 +45,15 @@ class Offset(NamedTuple):
 '''
 
 
-lorem = Text.from_markup(
-    """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
-    """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
+lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum."""
+lorem = (
+    lorem_short
+    + """ In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
 )
 
+lorem_short_text = Text.from_markup(lorem_short)
+lorem_long_text = Text.from_markup(lorem * 2)
+
 
 class TweetHeader(Widget):
     def render(self, style: Style) -> RenderableType:
@@ -56,8 +61,10 @@ class TweetHeader(Widget):
 
 
 class TweetBody(Widget):
+    short_lorem = Reactive[bool](False)
+
     def render(self, style: Style) -> Text:
-        return lorem
+        return lorem_short_text if self.short_lorem else lorem_long_text
 
 
 class Tweet(Widget):
@@ -135,9 +142,18 @@ class BasicApp(App):
     def key_d(self):
         self.dark = not self.dark
 
+    async def key_q(self):
+        await self.shutdown()
+
     def key_x(self):
         self.panic(self.tree)
 
+    def key_t(self):
+        # Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one.
+        tweet_body = self.screen.query("TweetBody").first()
+        tweet_body.short_lorem = not tweet_body.short_lorem
+        tweet_body.refresh(layout=True)
+
 
 app = BasicApp(
     css_path="basic.css",
diff --git a/src/textual/app.py b/src/textual/app.py
index dc923223c..b815485f3 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -114,7 +114,6 @@ class App(Generic[ReturnType], DOMNode):
         driver_class: Type[Driver] | None = None,
         log_path: str | PurePath = "",
         log_verbosity: int = 1,
-        # TODO: make this Literal a proper type in Rich, so we re-use it?
         log_color_system: Literal[
             "auto", "standard", "256", "truecolor", "windows"
         ] = "auto",
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index 79935716e..a81df3d81 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -34,6 +34,7 @@ from .constants import (
     VALID_OVERFLOW,
     VALID_VISIBILITY,
     VALID_STYLE_FLAGS,
+    VALID_SCROLLBAR_GUTTER,
 )
 from .errors import DeclarationError, StyleValueError
 from .model import Declaration
@@ -770,6 +771,18 @@ class StylesBuilder:
     process_content_align_horizontal = process_align_horizontal
     process_content_align_vertical = process_align_vertical
 
+    def process_scrollbar_gutter(self, name: str, tokens: list[Token]) -> None:
+        try:
+            value = self._process_enum(name, tokens, VALID_SCROLLBAR_GUTTER)
+        except StyleValueError:
+            self.error(
+                name,
+                tokens[0],
+                string_enum_help_text(name, VALID_SCROLLBAR_GUTTER, context="css"),
+            )
+        else:
+            self.styles._rules[name.replace("-", "_")] = value
+
     def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
         """
         Returns a valid CSS property "Python" name, or None if no close matches could be found.
diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py
index 6acf98cd3..51f8cf7ae 100644
--- a/src/textual/css/constants.py
+++ b/src/textual/css/constants.py
@@ -31,6 +31,7 @@ VALID_BOX_SIZING: Final = {"border-box", "content-box"}
 VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}
 VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"}
 VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"}
+VALID_SCROLLBAR_GUTTER: Final = {"auto", "stable"}
 VALID_STYLE_FLAGS: Final = {
     "none",
     "not",
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index 681ed797d..3199a28d2 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -38,6 +38,7 @@ from .constants import (
     VALID_DISPLAY,
     VALID_VISIBILITY,
     VALID_OVERFLOW,
+    VALID_SCROLLBAR_GUTTER,
 )
 from .scalar import Scalar, ScalarOffset, Unit
 from .scalar_animation import ScalarAnimation
@@ -52,6 +53,7 @@ from .types import (
     Specificity4,
     AlignVertical,
     Visibility,
+    ScrollbarGutter,
 )
 
 if sys.version_info >= (3, 8):
@@ -125,6 +127,8 @@ class RulesMap(TypedDict, total=False):
     scrollbar_background_hover: Color
     scrollbar_background_active: Color
 
+    scrollbar_gutter: ScrollbarGutter
+
     align_horizontal: AlignHorizontal
     align_vertical: AlignVertical
 
@@ -222,6 +226,8 @@ class StylesBase(ABC):
     scrollbar_background_hover = ColorProperty("#444444")
     scrollbar_background_active = ColorProperty("black")
 
+    scrollbar_gutter = StringEnumProperty(VALID_SCROLLBAR_GUTTER, "auto")
+
     align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
     align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
 
@@ -656,6 +662,8 @@ class Styles(StylesBase):
             append_declaration("overflow-x", self.overflow_x)
         if has_rule("overflow-y"):
             append_declaration("overflow-y", self.overflow_y)
+        if has_rule("scrollbar-gutter"):
+            append_declaration("scrollbar-gutter", self.scrollbar_gutter)
 
         if has_rule("box-sizing"):
             append_declaration("box-sizing", self.box_sizing)
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
index d4831998b..272a0fa3b 100644
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -316,7 +316,7 @@ class Stylesheet:
         styles = node.styles
         base_styles = styles.base
 
-        # Styles currently used an new rules
+        # Styles currently used on new rules
         modified_rule_keys = {*base_styles.get_rules().keys(), *rules.keys()}
         # Current render rules (missing rules are filled with default)
         current_render_rules = styles.get_render_rules()
diff --git a/src/textual/css/types.py b/src/textual/css/types.py
index a9f2db433..ac2183607 100644
--- a/src/textual/css/types.py
+++ b/src/textual/css/types.py
@@ -32,6 +32,7 @@ Visibility = Literal["visible", "hidden", "initial", "inherit"]
 Display = Literal["block", "none"]
 AlignHorizontal = Literal["left", "center", "right"]
 AlignVertical = Literal["top", "middle", "bottom"]
+ScrollbarGutter = Literal["auto", "stable"]
 BoxSizing = Literal["border-box", "content-box"]
 Overflow = Literal["scroll", "hidden", "auto"]
 EdgeStyle = Tuple[str, Color]
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 888bea7f9..65925b8e3 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -493,6 +493,9 @@ class Widget(DOMNode):
             Region: The widget region minus scrollbars.
         """
         show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
+        if self.styles.scrollbar_gutter == "stable":
+            # Let's _always_ reserve some space, whether the scrollbar is actually displayed or not:
+            show_vertical_scrollbar = True
         if show_horizontal_scrollbar and show_vertical_scrollbar:
             (region, _, _, _) = region.split(-1, -1)
         elif show_vertical_scrollbar:


Test patch:
diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py
index 02443439a..5d406bcf3 100644
--- a/tests/css/test_styles.py
+++ b/tests/css/test_styles.py
@@ -1,9 +1,16 @@
+import sys
 from decimal import Decimal
 
+if sys.version_info >= (3, 10):
+    from typing import Literal
+else:  # pragma: no cover
+    from typing_extensions import Literal
+
 import pytest
 
 from rich.style import Style
 
+from textual.app import ComposeResult
 from textual.color import Color
 from textual.css.errors import StyleValueError
 from textual.css.scalar import Scalar, Unit
@@ -11,6 +18,8 @@ from textual.css.styles import Styles, RenderStyles
 from textual.dom import DOMNode
 from textual.widget import Widget
 
+from tests.utilities.test_app import AppTest
+
 
 def test_styles_reset():
     styles = Styles()
@@ -185,3 +194,77 @@ def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_in
 
     with pytest.raises(StyleValueError):
         widget.styles.width = size_dimension_input
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "overflow_y,scrollbar_gutter,text_length,expected_text_widget_width,expects_vertical_scrollbar",
+    (
+        # ------------------------------------------------
+        # ----- Let's start with `overflow-y: auto`:
+        # short text: full width, no scrollbar
+        ["auto", "auto", "short_text", 80, False],
+        # long text: reduced width, scrollbar
+        ["auto", "auto", "long_text", 79, True],
+        # short text, `scrollbar-gutter: stable`: reduced width, no scrollbar
+        ["auto", "stable", "short_text", 79, False],
+        # long text, `scrollbar-gutter: stable`: reduced width, scrollbar
+        ["auto", "stable", "long_text", 79, True],
+        # ------------------------------------------------
+        # ----- And now let's see the behaviour with `overflow-y: scroll`:
+        # short text: reduced width, scrollbar
+        ["scroll", "auto", "short_text", 79, True],
+        # long text: reduced width, scrollbar
+        ["scroll", "auto", "long_text", 79, True],
+        # short text, `scrollbar-gutter: stable`: reduced width, scrollbar
+        ["scroll", "stable", "short_text", 79, True],
+        # long text, `scrollbar-gutter: stable`: reduced width, scrollbar
+        ["scroll", "stable", "long_text", 79, True],
+        # ------------------------------------------------
+        # ----- Finally, let's check the behaviour with `overflow-y: hidden`:
+        # short text: full width, no scrollbar
+        ["hidden", "auto", "short_text", 80, False],
+        # long text: full width, no scrollbar
+        ["hidden", "auto", "long_text", 80, False],
+        # short text, `scrollbar-gutter: stable`: reduced width, no scrollbar
+        ["hidden", "stable", "short_text", 79, False],
+        # long text, `scrollbar-gutter: stable`: reduced width, no scrollbar
+        ["hidden", "stable", "long_text", 79, False],
+    ),
+)
+async def test_scrollbar_gutter(
+    overflow_y: str,
+    scrollbar_gutter: str,
+    text_length: Literal["short_text", "long_text"],
+    expected_text_widget_width: int,
+    expects_vertical_scrollbar: bool,
+):
+    from rich.text import Text
+    from textual.geometry import Size
+
+    class TextWidget(Widget):
+        def render(self, styles) -> Text:
+            text_multiplier = 10 if text_length == "long_text" else 2
+            return Text(
+                "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a."
+                * text_multiplier
+            )
+
+    container = Widget()
+    container.styles.height = 3
+    container.styles.overflow_y = overflow_y
+    container.styles.scrollbar_gutter = scrollbar_gutter
+
+    text_widget = TextWidget()
+    text_widget.styles.height = "auto"
+    container.add_child(text_widget)
+
+    class MyTestApp(AppTest):
+        def compose(self) -> ComposeResult:
+            yield container
+
+    app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10))
+    await app.boot_and_shutdown()
+
+    assert text_widget.size.width == expected_text_widget_width
+    assert container.scrollbars_enabled[0] is expects_vertical_scrollbar
diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py
index 9271298cf..a46ef90fb 100644
--- a/tests/utilities/test_app.py
+++ b/tests/utilities/test_app.py
@@ -38,6 +38,9 @@ class AppTest(App):
             log_color_system="256",
         )
 
+        # Let's disable all features by default
+        self.features = frozenset()
+
         # We need this so the `CLEAR_SCREEN_SEQUENCE` is always sent for a screen refresh,
         # whatever the environment:
         self._sync_available = True
@@ -90,6 +93,19 @@ class AppTest(App):
 
         return get_running_state_context_manager()
 
+    async def boot_and_shutdown(
+        self,
+        *,
+        waiting_duration_after_initialisation: float = 0.001,
+        waiting_duration_before_shutdown: float = 0,
+    ):
+        """Just a commodity shortcut for `async with app.in_running_state(): pass`, for simple cases"""
+        async with self.in_running_state(
+            waiting_duration_after_initialisation=waiting_duration_after_initialisation,
+            waiting_duration_post_yield=waiting_duration_before_shutdown,
+        ):
+            pass
+
     def run(self):
         raise NotImplementedError(
             "Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`"