diff --git a/sandbox/uber.css b/sandbox/uber.css index 80f234300..cc063fab8 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -2,7 +2,7 @@ layout: vertical; background: dark_green; - overflow: hidden auto; + overflow: auto auto; border: heavy white; } @@ -10,4 +10,5 @@ height: 8; min-width: 80; background: dark_blue; + margin-bottom: 4; } diff --git a/sandbox/uber.py b/sandbox/uber.py index 37fd74acb..931b5d1a5 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -1,7 +1,9 @@ +import random import sys from textual import events from textual.app import App +from textual.geometry import Spacing from textual.widget import Widget from textual.widgets import Placeholder @@ -14,8 +16,13 @@ class BasicApp(App): self.bind("d", "dump") self.bind("t", "log_tree") self.bind("p", "print") + self.bind("o", "toggle_visibility") + self.bind("p", "toggle_display") + self.bind("f", "modify_first_child") + self.bind("b", "toggle_border") + self.bind("m", "increase_margin") - def on_mount(self): + async def on_mount(self): """Build layout here.""" uber2 = Widget() @@ -23,8 +30,9 @@ class BasicApp(App): Widget(id="uber2-child1"), Widget(id="uber2-child2"), ) + self.first_child = Placeholder(id="child1", classes={"list-item"}) uber1 = Widget( - Placeholder(id="child1", classes={"list-item"}), + self.first_child, Placeholder(id="child2", classes={"list-item"}), Placeholder(id="child3", classes={"list-item"}), Placeholder(classes={"list-item"}), @@ -32,6 +40,7 @@ class BasicApp(App): Placeholder(classes={"list-item"}), ) self.mount(uber1=uber1) + await self.first_child.focus() async def on_key(self, event: events.Key) -> None: await self.dispatch_key(event) @@ -55,5 +64,31 @@ class BasicApp(App): sys.stdout.write("abcdef") + def action_modify_first_child(self): + """Increment height of first child widget, randomise border and bg color""" + previous_height = self.focused.styles.height.value + new_height = previous_height + 1 + self.focused.styles.height = self.focused.styles.height.copy_with( + value=new_height + ) + color = random.choice(["red", "green", "blue"]) + self.focused.styles.background = color + self.focused.styles.border = ("dashed", color) + + def action_toggle_visibility(self): + self.focused.visible = not self.focused.visible + + def action_toggle_display(self): + # TODO: Doesn't work + self.focused.display = not self.focused.display + + def action_toggle_border(self): + self.focused.styles.border = [("solid", "red"), ("solid", "white")] + + def action_increase_margin(self): + old_margin = self.focused.styles.margin + new_margin = old_margin + Spacing.all(1) + self.focused.styles.margin = new_margin + BasicApp.run(css_file="uber.css", log="textual.log", log_verbosity=1) diff --git a/src/textual/app.py b/src/textual/app.py index 3a63f37d6..a70ad8279 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -439,7 +439,7 @@ class App(DOMNode): await widget.post_message(events.MouseCapture(self, self.mouse_position)) def panic(self, *renderables: RenderableType) -> None: - """Exits the app after displaying a message. + """Exits the app then displays a message. Args: *renderables (RenderableType, optional): Rich renderables to display on exit. diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index af4a9cdd7..c059d8cc8 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -14,7 +14,6 @@ from typing import Iterable, NamedTuple, TYPE_CHECKING, cast import rich.repr from rich.style import Style -from .. import log from ..color import Color, ColorPair from ._error_tools import friendly_list from .constants import NULL_SPACING @@ -34,10 +33,8 @@ if TYPE_CHECKING: from ..layout import Layout from .styles import DockGroup, Styles, StylesBase - from .types import EdgeType - BorderDefinition = ( "Sequence[tuple[EdgeType, str | Color] | None] | tuple[EdgeType, str | Color]" ) @@ -71,13 +68,15 @@ class ScalarProperty: value = obj.get_rule(self.name) return value - def __set__(self, obj: StylesBase, value: float | Scalar | str | None) -> None: + def __set__( + self, obj: StylesBase, value: float | int | Scalar | str | None + ) -> None: """Set the scalar property Args: obj (Styles): The ``Styles`` object. - value (float | Scalar | str | None): The value to set the scalar property to. - You can directly pass a float value, which will be interpreted with + value (float | int | Scalar | str | None): The value to set the scalar property to. + You can directly pass a float or int value, which will be interpreted with a default unit of Cells. You may also provide a string such as ``"50%"``, as you might do when writing CSS. If a string with no units is supplied, Cells will be used as the unit. Alternatively, you can directly supply @@ -89,8 +88,9 @@ class ScalarProperty: """ if value is None: obj.clear_rule(self.name) + obj.refresh(layout=True) return - if isinstance(value, float): + if isinstance(value, float) or isinstance(value, int): new_value = Scalar(float(value), Unit.CELLS, Unit.WIDTH) elif isinstance(value, Scalar): new_value = value @@ -100,7 +100,7 @@ class ScalarProperty: except ScalarParseError: raise StyleValueError("unable to parse scalar from {value!r}") else: - raise StyleValueError("expected float, Scalar, or None") + raise StyleValueError("expected float, int, Scalar, or None") if new_value is not None and new_value.unit not in self.units: raise StyleValueError( f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}" @@ -108,7 +108,7 @@ class ScalarProperty: if new_value is not None and new_value.is_percent: new_value = Scalar(float(new_value.value), self.percent_unit, Unit.WIDTH) if obj.set_rule(self.name, new_value): - obj.refresh() + obj.refresh(layout=True) class BoxProperty: @@ -208,7 +208,15 @@ class Edges(NamedTuple): class BorderProperty: - """Descriptor for getting and setting full borders and outlines.""" + """Descriptor for getting and setting full borders and outlines. + + Args: + layout (bool): True if the layout should be refreshed after setting, False otherwise. + + """ + + def __init__(self, layout: bool) -> None: + self._layout = layout def __set_name__(self, owner: StylesBase, name: str) -> None: self.name = name @@ -262,20 +270,22 @@ class BorderProperty: StyleValueError: When the supplied ``tuple`` is not of valid length (1, 2, or 4). """ top, right, bottom, left = self._properties - obj.refresh() if border is None: clear_rule = obj.clear_rule clear_rule(top) clear_rule(right) clear_rule(bottom) clear_rule(left) + obj.refresh(layout=self._layout) return if isinstance(border, tuple): setattr(obj, top, border) setattr(obj, right, border) setattr(obj, bottom, border) setattr(obj, left, border) + obj.refresh(layout=self._layout) return + count = len(border) if count == 1: _border = border[0] @@ -286,8 +296,8 @@ class BorderProperty: elif count == 2: _border1, _border2 = border setattr(obj, top, _border1) - setattr(obj, right, _border1) - setattr(obj, bottom, _border2) + setattr(obj, bottom, _border1) + setattr(obj, right, _border2) setattr(obj, left, _border2) elif count == 4: _border1, _border2, _border3, _border4 = border @@ -297,6 +307,7 @@ class BorderProperty: setattr(obj, left, _border4) else: raise StyleValueError("expected 1, 2, or 4 values") + obj.refresh(layout=self._layout) class StyleProperty: @@ -537,9 +548,10 @@ class StringEnumProperty: value belongs in the set of valid values. """ - def __init__(self, valid_values: set[str], default: str) -> None: + def __init__(self, valid_values: set[str], default: str, layout=False) -> None: self._valid_values = valid_values self._default = default + self._layout = layout def __set_name__(self, owner: StylesBase, name: str) -> None: self.name = name @@ -569,14 +581,14 @@ class StringEnumProperty: if value is None: if obj.clear_rule(self.name): - obj.refresh() + obj.refresh(layout=self._layout) else: if value not in self._valid_values: raise StyleValueError( f"{self.name} must be one of {friendly_list(self._valid_values)}" ) if obj.set_rule(self.name, value): - obj.refresh() + obj.refresh(layout=self._layout) class NameProperty: diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 9ed1a0e4d..b10043f1a 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -156,6 +156,25 @@ class Scalar(NamedTuple): raise ScalarResolveError(f"expected dimensions; found {str(self)!r}") return dimension + def copy_with( + self, + value: float | None = None, + unit: Unit | None = None, + percent_unit: Unit | None = None, + ) -> Scalar: + """Get a copy of this Scalar, with values optionally modified + + Args: + value (float | None): The new value, or None to keep the same value + unit (Unit | None): The new unit, or None to keep the same unit + percent_unit (Unit | None): The new percent_unit, or None to keep the same unit + """ + return Scalar( + value if value is not None else self.value, + unit if unit is not None else self.unit, + percent_unit if percent_unit is not None else self.percent_unit, + ) + @rich.repr.auto(angular=True) class ScalarOffset(NamedTuple): diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 89249ffef..34fcd2fc1 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -59,10 +59,9 @@ if TYPE_CHECKING: class RulesMap(TypedDict, total=False): """A typed dict for CSS rules. - Any key may be absent, indiciating that rule has not been set. + Any key may be absent, indicating that rule has not been set. Does not define composite rules, that is a rule that is made of a combination of other rules. - """ display: Display @@ -150,7 +149,7 @@ class StylesBase(ABC): "scrollbar_background_active", } - display = StringEnumProperty(VALID_DISPLAY, "block") + display = StringEnumProperty(VALID_DISPLAY, "block", layout=True) visibility = StringEnumProperty(VALID_VISIBILITY, "visible") layout = LayoutProperty() @@ -164,19 +163,19 @@ class StylesBase(ABC): margin = SpacingProperty() offset = OffsetProperty() - border = BorderProperty() + border = BorderProperty(layout=True) border_top = BoxProperty(Color(0, 255, 0)) border_right = BoxProperty(Color(0, 255, 0)) border_bottom = BoxProperty(Color(0, 255, 0)) border_left = BoxProperty(Color(0, 255, 0)) - outline = BorderProperty() + outline = BorderProperty(layout=False) outline_top = BoxProperty(Color(0, 255, 0)) outline_right = BoxProperty(Color(0, 255, 0)) outline_bottom = BoxProperty(Color(0, 255, 0)) outline_left = BoxProperty(Color(0, 255, 0)) - box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box") + box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box", layout=True) width = ScalarProperty(percent_unit=Unit.WIDTH) height = ScalarProperty(percent_unit=Unit.HEIGHT) min_width = ScalarProperty(percent_unit=Unit.WIDTH) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 5fccc2728..1a8ab67bc 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -4,16 +4,13 @@ Functions and classes to manage terminal geometry (anything involving coordinate """ - from __future__ import annotations from math import sqrt from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar - SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]] - T = TypeVar("T", int, float) @@ -632,11 +629,11 @@ class Spacing(NamedTuple): def unpack(cls, pad: SpacingDimensions) -> Spacing: """Unpack padding specified in CSS style.""" if isinstance(pad, int): - return cls(pad, pad, pad, pad) + return Spacing.all(pad) pad_len = len(pad) if pad_len == 1: _pad = pad[0] - return cls(_pad, _pad, _pad, _pad) + return Spacing.all(_pad) if pad_len == 2: pad_top, pad_right = cast(Tuple[int, int], pad) return cls(pad_top, pad_right, pad_top, pad_right) @@ -645,6 +642,18 @@ class Spacing(NamedTuple): return cls(top, right, bottom, left) raise ValueError(f"1, 2 or 4 integers required for spacing; {pad_len} given") + @classmethod + def vertical(cls, amount: int) -> Spacing: + return Spacing(amount, 0, amount, 0) + + @classmethod + def horizontal(cls, amount: int) -> Spacing: + return Spacing(0, amount, 0, amount) + + @classmethod + def all(cls, amount: int) -> Spacing: + return Spacing(amount, amount, amount, amount) + def __add__(self, other: object) -> Spacing: if isinstance(other, tuple): top1, right1, bottom1, left1 = self