mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #1610 from Textualize/fix-1607
Fix #1607 to allow programmatic style changes
This commit is contained in:
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed stuck screen https://github.com/Textualize/textual/issues/1632
|
- Fixed stuck screen https://github.com/Textualize/textual/issues/1632
|
||||||
|
- Fixed programmatic style changes not refreshing children layouts when parent widget did not change size https://github.com/Textualize/textual/issues/1607
|
||||||
- Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406
|
- Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406
|
||||||
- Fixed bug with animations that were triggered back to back, where the second one wouldn't start https://github.com/Textualize/textual/issues/1372
|
- Fixed bug with animations that were triggered back to back, where the second one wouldn't start https://github.com/Textualize/textual/issues/1372
|
||||||
- Fixed bug with animations that were scheduled where all but the first would be skipped https://github.com/Textualize/textual/issues/1372
|
- Fixed bug with animations that were scheduled where all but the first would be skipped https://github.com/Textualize/textual/issues/1372
|
||||||
|
|||||||
@@ -60,9 +60,23 @@ PropertySetType = TypeVar("PropertySetType")
|
|||||||
|
|
||||||
|
|
||||||
class GenericProperty(Generic[PropertyGetType, PropertySetType]):
|
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.default = default
|
||||||
self.layout = layout
|
self.layout = layout
|
||||||
|
self.refresh_children = refresh_children
|
||||||
|
|
||||||
def validate_value(self, value: object) -> PropertyGetType:
|
def validate_value(self, value: object) -> PropertyGetType:
|
||||||
"""Validate the setter value.
|
"""Validate the setter value.
|
||||||
@@ -88,11 +102,11 @@ class GenericProperty(Generic[PropertyGetType, PropertySetType]):
|
|||||||
_rich_traceback_omit = True
|
_rich_traceback_omit = True
|
||||||
if value is None:
|
if value is None:
|
||||||
obj.clear_rule(self.name)
|
obj.clear_rule(self.name)
|
||||||
obj.refresh(layout=self.layout)
|
obj.refresh(layout=self.layout, children=self.refresh_children)
|
||||||
return
|
return
|
||||||
new_value = self.validate_value(value)
|
new_value = self.validate_value(value)
|
||||||
if obj.set_rule(self.name, new_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]):
|
class IntegerProperty(GenericProperty[int, int]):
|
||||||
@@ -202,8 +216,16 @@ class ScalarProperty:
|
|||||||
|
|
||||||
|
|
||||||
class ScalarListProperty:
|
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.percent_unit = percent_unit
|
||||||
|
self.refresh_children = refresh_children
|
||||||
|
|
||||||
def __set_name__(self, owner: Styles, name: str) -> None:
|
def __set_name__(self, owner: Styles, name: str) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -218,7 +240,7 @@ class ScalarListProperty:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if value is None:
|
if value is None:
|
||||||
obj.clear_rule(self.name)
|
obj.clear_rule(self.name)
|
||||||
obj.refresh(layout=True)
|
obj.refresh(layout=True, children=self.refresh_children)
|
||||||
return
|
return
|
||||||
parse_values: Iterable[str | float]
|
parse_values: Iterable[str | float]
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@@ -237,7 +259,7 @@ class ScalarListProperty:
|
|||||||
else parse_value
|
else parse_value
|
||||||
)
|
)
|
||||||
if obj.set_rule(self.name, tuple(scalars)):
|
if obj.set_rule(self.name, tuple(scalars)):
|
||||||
obj.refresh(layout=True)
|
obj.refresh(layout=True, children=self.refresh_children)
|
||||||
|
|
||||||
|
|
||||||
class BoxProperty:
|
class BoxProperty:
|
||||||
@@ -682,12 +704,25 @@ class OffsetProperty:
|
|||||||
class StringEnumProperty:
|
class StringEnumProperty:
|
||||||
"""Descriptor for getting and setting string properties and ensuring that the set
|
"""Descriptor for getting and setting string properties and ensuring that the set
|
||||||
value belongs in the set of valid values.
|
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._valid_values = valid_values
|
||||||
self._default = default
|
self._default = default
|
||||||
self._layout = layout
|
self._layout = layout
|
||||||
|
self._refresh_children = refresh_children
|
||||||
|
|
||||||
def __set_name__(self, owner: StylesBase, name: str) -> None:
|
def __set_name__(self, owner: StylesBase, name: str) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -721,7 +756,7 @@ class StringEnumProperty:
|
|||||||
if value is None:
|
if value is None:
|
||||||
if obj.clear_rule(self.name):
|
if obj.clear_rule(self.name):
|
||||||
self._before_refresh(obj, value)
|
self._before_refresh(obj, value)
|
||||||
obj.refresh(layout=self._layout)
|
obj.refresh(layout=self._layout, children=self._refresh_children)
|
||||||
else:
|
else:
|
||||||
if value not in self._valid_values:
|
if value not in self._valid_values:
|
||||||
raise StyleValueError(
|
raise StyleValueError(
|
||||||
@@ -734,7 +769,7 @@ class StringEnumProperty:
|
|||||||
)
|
)
|
||||||
if obj.set_rule(self.name, value):
|
if obj.set_rule(self.name, value):
|
||||||
self._before_refresh(obj, value)
|
self._before_refresh(obj, value)
|
||||||
obj.refresh(layout=self._layout)
|
obj.refresh(layout=self._layout, children=self._refresh_children)
|
||||||
|
|
||||||
|
|
||||||
class OverflowProperty(StringEnumProperty):
|
class OverflowProperty(StringEnumProperty):
|
||||||
|
|||||||
@@ -265,26 +265,36 @@ class StylesBase(ABC):
|
|||||||
scrollbar_background_hover = ColorProperty("#444444")
|
scrollbar_background_hover = ColorProperty("#444444")
|
||||||
scrollbar_background_active = ColorProperty("black")
|
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_vertical = IntegerProperty(default=1, layout=True)
|
||||||
scrollbar_size_horizontal = IntegerProperty(default=1, layout=True)
|
scrollbar_size_horizontal = IntegerProperty(default=1, layout=True)
|
||||||
|
|
||||||
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
align_horizontal = StringEnumProperty(
|
||||||
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
VALID_ALIGN_HORIZONTAL, "left", layout=True, refresh_children=True
|
||||||
|
)
|
||||||
|
align_vertical = StringEnumProperty(
|
||||||
|
VALID_ALIGN_VERTICAL, "top", layout=True, refresh_children=True
|
||||||
|
)
|
||||||
align = AlignProperty()
|
align = AlignProperty()
|
||||||
|
|
||||||
content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||||
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||||
content_align = AlignProperty()
|
content_align = AlignProperty()
|
||||||
|
|
||||||
grid_rows = ScalarListProperty(percent_unit=Unit.HEIGHT)
|
grid_rows = ScalarListProperty(percent_unit=Unit.HEIGHT, refresh_children=True)
|
||||||
grid_columns = ScalarListProperty(percent_unit=Unit.WIDTH)
|
grid_columns = ScalarListProperty(percent_unit=Unit.WIDTH, refresh_children=True)
|
||||||
|
|
||||||
grid_size_columns = IntegerProperty(default=1, layout=True)
|
grid_size_columns = IntegerProperty(default=1, layout=True, refresh_children=True)
|
||||||
grid_size_rows = IntegerProperty(default=0, layout=True)
|
grid_size_rows = IntegerProperty(default=0, layout=True, refresh_children=True)
|
||||||
grid_gutter_horizontal = IntegerProperty(default=0, layout=True)
|
grid_gutter_horizontal = IntegerProperty(
|
||||||
grid_gutter_vertical = IntegerProperty(default=0, layout=True)
|
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)
|
row_span = IntegerProperty(default=1, layout=True)
|
||||||
column_span = IntegerProperty(default=1, layout=True)
|
column_span = IntegerProperty(default=1, layout=True)
|
||||||
|
|||||||
95
tests/css/test_programmatic_style_changes.py
Normal file
95
tests/css/test_programmatic_style_changes.py
Normal file
@@ -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)
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
@@ -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()
|
||||||
@@ -179,6 +179,16 @@ def test_nested_auto_heights(snap_compare):
|
|||||||
assert snap_compare("snapshot_apps/nested_auto_heights.py", press=["1", "2", "_"])
|
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 ---
|
# --- Other ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user