Repository: Textualize/textual

Issue:
Programmatic styles assignment doesn't refresh children widgets
Investigation of #1481 surfaced a more fundamental issue, in that programmatically changing some styles of a widget `W` doesn't refresh the children of `W` and hence doesn't update the app to its correct state.

---
**Edit**:

The styles _found_ to be suffering from this are (not necessarily a comprehensive list):

 - `grid_size_rows`
 - `grid_size_columns`
 - `grid_gutter_vertical`
 - `grid_gutter_horizontal`
 - `grid_rows`
 - `grid_columns`
 - `overflow_x`
 - `overflow_y`
 - `scrollbar_gutter`
 - `align`
 - `align_horizontal`
 - `align_vertical`

---

Consider the app below that contains a 2 x 2 grid of boxes. Clicking the app should change it so that the grid now has 10 rows (instead of 2) but nothing changes. You have to force the layout update by resizing the terminal, for example.

Notice that the method `on_click` has many different lines commented. Each of those represents an attribute that can be programmatically changed without the layout of the app updating immediately and automatically.

```py
from textual.app import App
from textual.containers import Grid
from textual.widgets import Static


class Box(Static):
    DEFAULT_CSS = """Box { border: round green; width: 100%;  height: 100%; }"""


class MyApp(App[None]):
    DEFAULT_CSS = """
    Grid {
        grid-size: 2 2;
    }
    """

    def compose(self):
        yield Grid(
            Box(),
            Box(),
            Box(),
            Box(),
        )

    def on_click(self):
        self.query_one(Grid).styles.grid_size_rows = 10
        # self.query_one(Grid).styles.grid_size_columns = 10
        # self.query_one(Grid).styles.grid_gutter_vertical = 10
        # self.query_one(Grid).styles.grid_gutter_horizontal = 10
        # self.query_one(Grid).styles.grid_rows = "1fr 10fr"
        # self.query_one(Grid).styles.grid_columns = "1fr 10fr"


if __name__ == "__main__":
    MyApp().run()
```


Discussion:
The issue seems to be related to the fact that:

 1. the properties defined in `_style_properties.py` can be flagged to refresh the layout of the widget when they are updated, as seen in https://github.com/Textualize/textual/blob/27e9e46bd4bf9d6c07e11d2a2e00e724fce0b9b5/src/textual/css/_style_properties.py#L94-L95 
 This calls the method `Styles.refresh` that will defer to `Widget.refresh` but then we only update the children of the widget if the parameter `children` is set to `True` in the first call to `obj.refresh`, which doesn't happen for most properties; and
 2. the method `Widget.refresh` will propagate upwards in the hierarchy when `layout=True` but it won't propagate _down_, which I find puzzling (at least, at the moment), although it is consistent with the fact that `Styles.refresh` has to traverse _down_ the hierarchy explicitly.


Consider the app below. Its method `on_click` contains many programmatic style changes, neither of which are reflected automatically in the app layout, and they all involve **changing something in a parent widget that doesn't affect the position of the parent but affects the children**.

The app below also contains an `on_key` handler that calls the method `refresh(layout=True)` on our grid container, regardless of the key pressed. Notice how that is _not_ enough to update the app.

So, uncomment however many lines you want in `no_click`, run the app, click it a couple of times, then press a couple of keys, and nothing happened.
Then, resize the terminal and notice how a bunch of things just jump around:

```py
from textual.app import App
from textual.containers import Grid
from textual.widgets import Label


class MyLabel(Label):
    DEFAULT_CSS = """
    MyLabel {
        border: round green;
        width: 50%;
        height: 50%;
    }
    """


class MyApp(App[None]):
    DEFAULT_CSS = """
    Grid {
        grid-size: 2 2;
    }
    """

    def compose(self):
        yield Grid(
            MyLabel("one"),
            MyLabel("two"),
            MyLabel("three"),
            MyLabel("four"),
        )

    def on_click(self):
        self.query_one(Grid).styles.grid_size_rows = 10
        # self.query_one(Grid).styles.grid_size_columns = 10
        # self.query_one(Grid).styles.grid_gutter_vertical = 10
        # self.query_one(Grid).styles.grid_gutter_horizontal = 10
        # self.query_one(Grid).styles.grid_rows = "1fr 10fr"
        # self.query_one(Grid).styles.grid_columns = "1fr 10fr"
        # self.query_one(Grid).styles.overflow_x = "scroll"
        # self.query_one(Grid).styles.overflow_y = "scroll"
        # self.query_one(Grid).styles.scrollbar_gutter = "stable"
        # self.query_one(Grid).styles.align_horizontal = "right"
        # self.query_one(Grid).styles.align_vertical = "bottom"
        # self.query_one(Grid).styles.align = ("right", "bottom")

    def on_key(self, event):
        self.query_one(Grid).refresh(layout=True)


if __name__ == "__main__":
    MyApp().run()
```

Resolving patch:
diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -60,9 +60,23 @@
 
 
 class GenericProperty(Generic[PropertyGetType, PropertySetType]):
-    def __init__(self, default: PropertyGetType, layout: bool = False) -> None:
+    """Descriptor that abstracts away common machinery for other style descriptors.
+
+    Args:
+        default: The default value (or a factory thereof) of the property.
+        layout: Whether to refresh the node layout on value change.
+        refresh_children: Whether to refresh the node children on value change.
+    """
+
+    def __init__(
+        self,
+        default: PropertyGetType,
+        layout: bool = False,
+        refresh_children: bool = False,
+    ) -> None:
         self.default = default
         self.layout = layout
+        self.refresh_children = refresh_children
 
     def validate_value(self, value: object) -> PropertyGetType:
         """Validate the setter value.
@@ -88,11 +102,11 @@ def __set__(self, obj: StylesBase, value: PropertySetType | None) -> None:
         _rich_traceback_omit = True
         if value is None:
             obj.clear_rule(self.name)
-            obj.refresh(layout=self.layout)
+            obj.refresh(layout=self.layout, children=self.refresh_children)
             return
         new_value = self.validate_value(value)
         if obj.set_rule(self.name, new_value):
-            obj.refresh(layout=self.layout)
+            obj.refresh(layout=self.layout, children=self.refresh_children)
 
 
 class IntegerProperty(GenericProperty[int, int]):
@@ -202,8 +216,16 @@ def __set__(
 
 
 class ScalarListProperty:
-    def __init__(self, percent_unit: Unit) -> None:
+    """Descriptor for lists of scalars.
+
+    Args:
+        percent_unit: The dimension to which percentage scalars will be relative to.
+        refresh_children: Whether to refresh the node children on value change.
+    """
+
+    def __init__(self, percent_unit: Unit, refresh_children: bool = False) -> None:
         self.percent_unit = percent_unit
+        self.refresh_children = refresh_children
 
     def __set_name__(self, owner: Styles, name: str) -> None:
         self.name = name
@@ -218,7 +240,7 @@ def __set__(
     ) -> None:
         if value is None:
             obj.clear_rule(self.name)
-            obj.refresh(layout=True)
+            obj.refresh(layout=True, children=self.refresh_children)
             return
         parse_values: Iterable[str | float]
         if isinstance(value, str):
@@ -237,7 +259,7 @@ def __set__(
                     else parse_value
                 )
         if obj.set_rule(self.name, tuple(scalars)):
-            obj.refresh(layout=True)
+            obj.refresh(layout=True, children=self.refresh_children)
 
 
 class BoxProperty:
@@ -682,12 +704,25 @@ def __set__(
 class StringEnumProperty:
     """Descriptor for getting and setting string properties and ensuring that the set
     value belongs in the set of valid values.
+
+    Args:
+        valid_values: The set of valid values that the descriptor can take.
+        default: The default value (or a factory thereof) of the property.
+        layout: Whether to refresh the node layout on value change.
+        refresh_children: Whether to refresh the node children on value change.
     """
 
-    def __init__(self, valid_values: set[str], default: str, layout=False) -> None:
+    def __init__(
+        self,
+        valid_values: set[str],
+        default: str,
+        layout: bool = False,
+        refresh_children: bool = False,
+    ) -> None:
         self._valid_values = valid_values
         self._default = default
         self._layout = layout
+        self._refresh_children = refresh_children
 
     def __set_name__(self, owner: StylesBase, name: str) -> None:
         self.name = name
@@ -721,7 +756,7 @@ def __set__(self, obj: StylesBase, value: str | None = None):
         if value is None:
             if obj.clear_rule(self.name):
                 self._before_refresh(obj, value)
-                obj.refresh(layout=self._layout)
+                obj.refresh(layout=self._layout, children=self._refresh_children)
         else:
             if value not in self._valid_values:
                 raise StyleValueError(
@@ -734,7 +769,7 @@ def __set__(self, obj: StylesBase, value: str | None = None):
                 )
             if obj.set_rule(self.name, value):
                 self._before_refresh(obj, value)
-                obj.refresh(layout=self._layout)
+                obj.refresh(layout=self._layout, children=self._refresh_children)
 
 
 class OverflowProperty(StringEnumProperty):
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -265,26 +265,36 @@ class StylesBase(ABC):
     scrollbar_background_hover = ColorProperty("#444444")
     scrollbar_background_active = ColorProperty("black")
 
-    scrollbar_gutter = StringEnumProperty(VALID_SCROLLBAR_GUTTER, "auto")
+    scrollbar_gutter = StringEnumProperty(
+        VALID_SCROLLBAR_GUTTER, "auto", layout=True, refresh_children=True
+    )
 
     scrollbar_size_vertical = IntegerProperty(default=1, layout=True)
     scrollbar_size_horizontal = IntegerProperty(default=1, layout=True)
 
-    align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
-    align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
+    align_horizontal = StringEnumProperty(
+        VALID_ALIGN_HORIZONTAL, "left", layout=True, refresh_children=True
+    )
+    align_vertical = StringEnumProperty(
+        VALID_ALIGN_VERTICAL, "top", layout=True, refresh_children=True
+    )
     align = AlignProperty()
 
     content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
     content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
     content_align = AlignProperty()
 
-    grid_rows = ScalarListProperty(percent_unit=Unit.HEIGHT)
-    grid_columns = ScalarListProperty(percent_unit=Unit.WIDTH)
-
-    grid_size_columns = IntegerProperty(default=1, layout=True)
-    grid_size_rows = IntegerProperty(default=0, layout=True)
-    grid_gutter_horizontal = IntegerProperty(default=0, layout=True)
-    grid_gutter_vertical = IntegerProperty(default=0, layout=True)
+    grid_rows = ScalarListProperty(percent_unit=Unit.HEIGHT, refresh_children=True)
+    grid_columns = ScalarListProperty(percent_unit=Unit.WIDTH, refresh_children=True)
+
+    grid_size_columns = IntegerProperty(default=1, layout=True, refresh_children=True)
+    grid_size_rows = IntegerProperty(default=0, layout=True, refresh_children=True)
+    grid_gutter_horizontal = IntegerProperty(
+        default=0, layout=True, refresh_children=True
+    )
+    grid_gutter_vertical = IntegerProperty(
+        default=0, layout=True, refresh_children=True
+    )
 
     row_span = IntegerProperty(default=1, layout=True)
     column_span = IntegerProperty(default=1, layout=True)


Test patch:
diff --git a/tests/css/test_programmatic_style_changes.py b/tests/css/test_programmatic_style_changes.py
new file mode 100644
--- /dev/null
+++ b/tests/css/test_programmatic_style_changes.py
@@ -0,0 +1,95 @@
+import pytest
+
+from textual.app import App
+from textual.containers import Grid
+from textual.screen import Screen
+from textual.widgets import Label
+
+
+@pytest.mark.parametrize(
+    "style, value",
+    [
+        ("grid_size_rows", 3),
+        ("grid_size_columns", 3),
+        ("grid_gutter_vertical", 4),
+        ("grid_gutter_horizontal", 4),
+        ("grid_rows", "1fr 3fr"),
+        ("grid_columns", "1fr 3fr"),
+    ],
+)
+async def test_programmatic_style_change_updates_children(style: str, value: object):
+    """Regression test for #1607 https://github.com/Textualize/textual/issues/1607
+
+    Some programmatic style changes to a widget were not updating the layout of the
+    children widgets, which seemed to be happening when the style change did not affect
+    the size of the widget but did affect the layout of the children.
+
+    This test, in particular, checks the attributes that _should_ affect the size of the
+    children widgets.
+    """
+
+    class MyApp(App[None]):
+        CSS = """
+        Grid { grid-size: 2 2; }
+        Label { width: 100%; height: 100%; }
+        """
+
+        def compose(self):
+            yield Grid(
+                Label("one"),
+                Label("two"),
+                Label("three"),
+                Label("four"),
+            )
+
+    app = MyApp()
+
+    async with app.run_test() as pilot:
+        sizes = [(lbl.size.width, lbl.size.height) for lbl in app.screen.query(Label)]
+
+        setattr(app.query_one(Grid).styles, style, value)
+        await pilot.pause()
+
+        assert sizes != [
+            (lbl.size.width, lbl.size.height) for lbl in app.screen.query(Label)
+        ]
+
+
+@pytest.mark.parametrize(
+    "style, value",
+    [
+        ("align_horizontal", "right"),
+        ("align_vertical", "bottom"),
+        ("align", ("right", "bottom")),
+    ],
+)
+async def test_programmatic_align_change_updates_children_position(
+    style: str, value: str
+):
+    """Regression test for #1607 for the align(_xxx) styles.
+
+    See https://github.com/Textualize/textual/issues/1607.
+    """
+
+    class MyApp(App[None]):
+        CSS = "Grid { grid-size: 2 2; }"
+
+        def compose(self):
+            yield Grid(
+                Label("one"),
+                Label("two"),
+                Label("three"),
+                Label("four"),
+            )
+
+    app = MyApp()
+
+    async with app.run_test() as pilot:
+        offsets = [(lbl.region.x, lbl.region.y) for lbl in app.screen.query(Label)]
+
+        setattr(app.query_one(Grid).styles, style, value)
+        await pilot.pause()
+
+        assert offsets != [
+            (lbl.region.x, lbl.region.y) for lbl in app.screen.query(Label)
+        ]
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -13651,6 +13651,161 @@
   
   '''
 # ---
+# name: test_programmatic_scrollbar_gutter_change
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-2761669360-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-2761669360-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-2761669360-r1 { fill: #ffdddd }
+  .terminal-2761669360-r2 { fill: #c5c8c6 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-2761669360-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-2761669360-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-2761669360-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-2761669360-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ProgrammaticScrollbarGutterChange</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-2761669360-clip-terminal)">
+      <rect fill="#ff0000" x="0" y="1.5" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="36.6" y="1.5" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="1.5" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="488" y="1.5" width="427" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="1.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="25.9" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="25.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="25.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="50.3" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="50.3" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="50.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="74.7" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="74.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="99.1" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="99.1" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="99.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="123.5" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="123.5" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="123.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="147.9" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="147.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="147.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="172.3" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="172.3" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="172.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="196.7" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="196.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="221.1" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="221.1" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="221.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="245.5" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="245.5" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="245.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="269.9" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="269.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="269.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="294.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="61" y="294.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="294.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="294.3" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="294.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="318.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="318.7" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="318.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="343.1" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="343.1" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="343.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="367.5" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="367.5" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="367.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="391.9" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="391.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="391.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="416.3" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="416.3" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="416.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="440.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="440.7" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="440.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="465.1" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="465.1" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="465.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="489.5" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="489.5" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="489.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="513.9" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="513.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="513.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="538.3" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="538.3" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="538.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="562.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="451.4" y="562.7" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="915" y="562.7" width="61" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-2761669360-matrix">
+      <text class="terminal-2761669360-r1" x="0" y="20" textLength="36.6" clip-path="url(#terminal-2761669360-line-0)">one</text><text class="terminal-2761669360-r1" x="451.4" y="20" textLength="36.6" clip-path="url(#terminal-2761669360-line-0)">two</text><text class="terminal-2761669360-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-2761669360-line-0)">
+  </text><text class="terminal-2761669360-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-2761669360-line-1)">
+  </text><text class="terminal-2761669360-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-2761669360-line-2)">
+  </text><text class="terminal-2761669360-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-2761669360-line-3)">
+  </text><text class="terminal-2761669360-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-2761669360-line-4)">
+  </text><text class="terminal-2761669360-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-2761669360-line-5)">
+  </text><text class="terminal-2761669360-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-2761669360-line-6)">
+  </text><text class="terminal-2761669360-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-2761669360-line-7)">
+  </text><text class="terminal-2761669360-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-2761669360-line-8)">
+  </text><text class="terminal-2761669360-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-2761669360-line-9)">
+  </text><text class="terminal-2761669360-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-2761669360-line-10)">
+  </text><text class="terminal-2761669360-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-2761669360-line-11)">
+  </text><text class="terminal-2761669360-r1" x="0" y="312.8" textLength="61" clip-path="url(#terminal-2761669360-line-12)">three</text><text class="terminal-2761669360-r1" x="451.4" y="312.8" textLength="48.8" clip-path="url(#terminal-2761669360-line-12)">four</text><text class="terminal-2761669360-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-2761669360-line-12)">
+  </text><text class="terminal-2761669360-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-2761669360-line-13)">
+  </text><text class="terminal-2761669360-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-2761669360-line-14)">
+  </text><text class="terminal-2761669360-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-2761669360-line-15)">
+  </text><text class="terminal-2761669360-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-2761669360-line-16)">
+  </text><text class="terminal-2761669360-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-2761669360-line-17)">
+  </text><text class="terminal-2761669360-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-2761669360-line-18)">
+  </text><text class="terminal-2761669360-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-2761669360-line-19)">
+  </text><text class="terminal-2761669360-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-2761669360-line-20)">
+  </text><text class="terminal-2761669360-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-2761669360-line-21)">
+  </text><text class="terminal-2761669360-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-2761669360-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_textlog_max_lines
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
diff --git a/tests/snapshot_tests/snapshot_apps/programmatic_scrollbar_gutter_change.py b/tests/snapshot_tests/snapshot_apps/programmatic_scrollbar_gutter_change.py
new file mode 100644
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/programmatic_scrollbar_gutter_change.py
@@ -0,0 +1,29 @@
+from textual.app import App
+from textual.containers import Grid
+from textual.widgets import Label
+
+
+class ProgrammaticScrollbarGutterChange(App[None]):
+    CSS = """
+    Grid { grid-size: 2 2; scrollbar-size: 5 5; }
+    Label { width: 100%; height: 100%; background: red; }
+    """
+
+    def compose(self):
+        yield Grid(
+            Label("one"),
+            Label("two"),
+            Label("three"),
+            Label("four"),
+        )
+
+    def on_key(self, event):
+        if event.key == "s":
+            self.query_one(Grid).styles.scrollbar_gutter = "stable"
+
+
+app = ProgrammaticScrollbarGutterChange()
+
+
+if __name__ == "__main__":
+    app().run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -179,6 +179,16 @@ def test_nested_auto_heights(snap_compare):
     assert snap_compare("snapshot_apps/nested_auto_heights.py", press=["1", "2", "_"])
 
 
+def test_programmatic_scrollbar_gutter_change(snap_compare):
+    """Regression test for #1607 https://github.com/Textualize/textual/issues/1607
+
+    See also tests/css/test_programmatic_style_changes.py for other related regression tests.
+    """
+    assert snap_compare(
+        "snapshot_apps/programmatic_scrollbar_gutter_change.py", press=["s"]
+    )
+
+
 # --- Other ---