from __future__ import annotations from dataclasses import dataclass, field from functools import partial from operator import attrgetter from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Literal, cast import rich.repr from rich.style import Style from typing_extensions import TypedDict from textual._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from textual._types import AnimationLevel, CallbackType from textual.color import Color from textual.css._style_properties import ( AlignProperty, BooleanProperty, BorderProperty, BoxProperty, ColorProperty, DockProperty, FractionalProperty, HatchProperty, IntegerProperty, KeylineProperty, LayoutProperty, NameListProperty, NameProperty, OffsetProperty, OverflowProperty, ScalarListProperty, ScalarProperty, ScrollbarColorProperty, SpacingProperty, SplitProperty, StringEnumProperty, StyleFlagsProperty, TransitionsProperty, ) from textual.css.constants import ( VALID_ALIGN_HORIZONTAL, VALID_ALIGN_VERTICAL, VALID_BOX_SIZING, VALID_CONSTRAIN, VALID_DISPLAY, VALID_EXPAND, VALID_OVERFLOW, VALID_OVERLAY, VALID_POSITION, VALID_SCROLLBAR_GUTTER, VALID_SCROLLBAR_VISIBILITY, VALID_TEXT_ALIGN, VALID_TEXT_OVERFLOW, VALID_TEXT_WRAP, VALID_VISIBILITY, ) from textual.css.scalar import Scalar, ScalarOffset, Unit from textual.css.scalar_animation import ScalarAnimation from textual.css.transition import Transition from textual.css.types import ( AlignHorizontal, AlignVertical, BoxSizing, Constrain, Display, Expand, Overflow, Overlay, ScrollbarGutter, Specificity3, Specificity6, TextAlign, TextOverflow, TextWrap, Visibility, ) from textual.geometry import Offset, Spacing if TYPE_CHECKING: from textual.css.types import CSSLocation from textual.dom import DOMNode from textual.layout import Layout class RulesMap(TypedDict, total=False): """A typed dict for CSS rules. 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 visibility: Visibility layout: "Layout" auto_color: bool color: Color background: Color text_style: Style background_tint: Color opacity: float text_opacity: float padding: Spacing margin: Spacing offset: ScalarOffset position: str border_top: tuple[str, Color] border_right: tuple[str, Color] border_bottom: tuple[str, Color] border_left: tuple[str, Color] border_title_align: AlignHorizontal border_subtitle_align: AlignHorizontal outline_top: tuple[str, Color] outline_right: tuple[str, Color] outline_bottom: tuple[str, Color] outline_left: tuple[str, Color] keyline: tuple[str, Color] box_sizing: BoxSizing width: Scalar height: Scalar min_width: Scalar min_height: Scalar max_width: Scalar max_height: Scalar dock: str split: str overflow_x: Overflow overflow_y: Overflow layers: tuple[str, ...] layer: str transitions: dict[str, Transition] tint: Color scrollbar_color: Color scrollbar_color_hover: Color scrollbar_color_active: Color scrollbar_corner_color: Color 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 content_align_horizontal: AlignHorizontal content_align_vertical: AlignVertical grid_size_rows: int grid_size_columns: int grid_gutter_horizontal: int grid_gutter_vertical: int grid_rows: tuple[Scalar, ...] grid_columns: tuple[Scalar, ...] row_span: int column_span: int text_align: TextAlign link_color: Color auto_link_color: bool link_background: Color link_style: Style link_color_hover: Color auto_link_color_hover: bool link_background_hover: Color link_style_hover: Style auto_border_title_color: bool border_title_color: Color border_title_background: Color border_title_style: Style auto_border_subtitle_color: bool border_subtitle_color: Color border_subtitle_background: Color border_subtitle_style: Style hatch: tuple[str, Color] | Literal["none"] overlay: Overlay constrain_x: Constrain constrain_y: Constrain text_wrap: TextWrap text_overflow: TextOverflow expand: Expand line_pad: int RULE_NAMES = list(RulesMap.__annotations__.keys()) RULE_NAMES_SET = frozenset(RULE_NAMES) _rule_getter = attrgetter(*RULE_NAMES) class StylesBase: """A common base class for Styles and RenderStyles""" ANIMATABLE = { "offset", "padding", "margin", "width", "height", "min_width", "min_height", "max_width", "max_height", "auto_color", "color", "background", "background_tint", "opacity", "position", "text_opacity", "tint", "scrollbar_color", "scrollbar_color_hover", "scrollbar_color_active", "scrollbar_background", "scrollbar_background_hover", "scrollbar_background_active", "scrollbar_visibility", "link_color", "link_background", "link_color_hover", "link_background_hover", "text_wrap", "text_overflow", "line_pad", } node: DOMNode | None = None display = StringEnumProperty(VALID_DISPLAY, "block", layout=True, display=True) """Set the display of the widget, defining how it's rendered. Valid values are "block" or "none". "none" will hide and allow other widgets to fill the space that this widget would occupy. Set to None to clear any value that was set at runtime. Raises: StyleValueError: If an invalid display is specified. """ visibility = StringEnumProperty(VALID_VISIBILITY, "visible", layout=True) """Set the visibility of the widget. Valid values are "visible" or "hidden". "hidden" will hide the widget, but reserve the space for this widget. If you want to hide the widget and allow another widget to fill the space, set the display attribute to "none" instead. Set to None to clear any value that was set at runtime. Raises: StyleValueError: If an invalid visibility is specified. """ layout = LayoutProperty() """Set the layout of the widget, defining how its children are laid out. Valid values are "grid", "stream", "horizontal", or "vertical" or None to clear any layout that was set at runtime. Raises: MissingLayout: If an invalid layout is specified. """ auto_color = BooleanProperty(default=False) """Enable automatic picking of best contrasting color.""" color = ColorProperty(Color(255, 255, 255)) """Set the foreground (text) color of the widget. Supports `Color` objects but also strings e.g. "red" or "#ff0000". You can also specify an opacity after a color e.g. "blue 10%" """ background = ColorProperty(Color(0, 0, 0, 0)) """Set the background color of the widget. Supports `Color` objects but also strings e.g. "red" or "#ff0000" You can also specify an opacity after a color e.g. "blue 10%" """ background_tint = ColorProperty(Color(0, 0, 0, 0)) """Set a color to tint (blend) with the background. Supports `Color` objects but also strings e.g. "red" or "#ff0000" You can also specify an opacity after a color e.g. "blue 10%" """ text_style = StyleFlagsProperty() """Set the text style of the widget using Rich StyleFlags. e.g. `"bold underline"` or `"b u strikethrough"`. """ opacity = FractionalProperty(children=True) """Set the opacity of the widget, defining how it blends with the parent.""" text_opacity = FractionalProperty() """Set the opacity of the content within the widget against the widget's background.""" padding = SpacingProperty() """Set the padding (spacing between border and content) of the widget.""" margin = SpacingProperty() """Set the margin (spacing outside the border) of the widget.""" offset = OffsetProperty() """Set the offset of the widget relative to where it would have been otherwise.""" position = StringEnumProperty(VALID_POSITION, "relative") """If `relative` offset is applied to widgets current position, if `absolute` it is applied to (0, 0).""" border = BorderProperty(layout=True) """Set the border of the widget e.g. ("round", "green") or "none".""" border_top = BoxProperty(Color(0, 255, 0)) """Set the top border of the widget e.g. ("round", "green") or "none".""" border_right = BoxProperty(Color(0, 255, 0)) """Set the right border of the widget e.g. ("round", "green") or "none".""" border_bottom = BoxProperty(Color(0, 255, 0)) """Set the bottom border of the widget e.g. ("round", "green") or "none".""" border_left = BoxProperty(Color(0, 255, 0)) """Set the left border of the widget e.g. ("round", "green") or "none".""" border_title_align = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") """The alignment of the border title text.""" border_subtitle_align = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "right") """The alignment of the border subtitle text.""" outline = BorderProperty(layout=False) """Set the outline of the widget e.g. ("round", "green") or "none". The outline is drawn *on top* of the widget, rather than around it like border. """ outline_top = BoxProperty(Color(0, 255, 0)) """Set the top outline of the widget e.g. ("round", "green") or "none".""" outline_right = BoxProperty(Color(0, 255, 0)) """Set the right outline of the widget e.g. ("round", "green") or "none".""" outline_bottom = BoxProperty(Color(0, 255, 0)) """Set the bottom outline of the widget e.g. ("round", "green") or "none".""" outline_left = BoxProperty(Color(0, 255, 0)) """Set the left outline of the widget e.g. ("round", "green") or "none".""" keyline = KeylineProperty() """Keyline parameters.""" box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box", layout=True) """Box sizing method ("border-box" or "conetnt-box")""" width = ScalarProperty(percent_unit=Unit.WIDTH) """Set the width of the widget.""" height = ScalarProperty(percent_unit=Unit.HEIGHT) """Set the height of the widget.""" min_width = ScalarProperty(percent_unit=Unit.WIDTH, allow_auto=False) """Set the minimum width of the widget.""" min_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False) """Set the minimum height of the widget.""" max_width = ScalarProperty(percent_unit=Unit.WIDTH, allow_auto=False) """Set the maximum width of the widget.""" max_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False) """Set the maximum height of the widget.""" dock = DockProperty() """Set which edge of the parent to dock this widget to e.g. "top", "left", "right", "bottom", "none". """ split = SplitProperty() overflow_x = OverflowProperty(VALID_OVERFLOW, "hidden") """Control what happens when the content extends horizontally beyond the widget's width. Valid values are "scroll", "hidden", or "auto". """ overflow_y = OverflowProperty(VALID_OVERFLOW, "hidden") """Control what happens when the content extends vertically beyond the widget's height. Valid values are "scroll", "hidden", or "auto". """ layer = NameProperty() layers = NameListProperty() transitions = TransitionsProperty() tint = ColorProperty("transparent") """Set the tint of the widget. This allows you apply an opaque color above the widget. You can specify an opacity after a color e.g. "blue 10%" """ scrollbar_color = ScrollbarColorProperty("ansi_bright_magenta") """Set the color of the handle of the scrollbar.""" scrollbar_color_hover = ScrollbarColorProperty("ansi_yellow") """Set the color of the handle of the scrollbar when hovered.""" scrollbar_color_active = ScrollbarColorProperty("ansi_bright_yellow") """Set the color of the handle of the scrollbar when active (being dragged).""" scrollbar_corner_color = ScrollbarColorProperty("#666666") """Set the color of the space between the horizontal and vertical scrollbars.""" scrollbar_background = ScrollbarColorProperty("#555555") """Set the background color of the scrollbar (the track that the handle sits on).""" scrollbar_background_hover = ScrollbarColorProperty("#444444") """Set the background color of the scrollbar when hovered.""" scrollbar_background_active = ScrollbarColorProperty("black") """Set the background color of the scrollbar when active (being dragged).""" scrollbar_gutter = StringEnumProperty( VALID_SCROLLBAR_GUTTER, "auto", layout=True, refresh_children=True ) """Set to "stable" to reserve space for the scrollbar even when it's not visible. This can prevent content from shifting when a scrollbar appears. """ scrollbar_size_vertical = IntegerProperty(default=2, layout=True) """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 ) 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, 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) text_align: StringEnumProperty[TextAlign] = StringEnumProperty( VALID_TEXT_ALIGN, "start" ) link_color = ColorProperty("transparent") auto_link_color = BooleanProperty(False) link_background = ColorProperty("transparent") link_style = StyleFlagsProperty() link_color_hover = ColorProperty("transparent") auto_link_color_hover = BooleanProperty(False) link_background_hover = ColorProperty("transparent") link_style_hover = StyleFlagsProperty() auto_border_title_color = BooleanProperty(default=False) border_title_color = ColorProperty(Color(255, 255, 255, 0)) border_title_background = ColorProperty(Color(0, 0, 0, 0)) border_title_style = StyleFlagsProperty() auto_border_subtitle_color = BooleanProperty(default=False) border_subtitle_color = ColorProperty(Color(255, 255, 255, 0)) border_subtitle_background = ColorProperty(Color(0, 0, 0, 0)) border_subtitle_style = StyleFlagsProperty() hatch = HatchProperty() """Add a hatched background effect e.g. ("right", "yellow") or "none" to use no hatch. """ overlay = StringEnumProperty( VALID_OVERLAY, "none", layout=True, refresh_parent=True ) constrain_x: StringEnumProperty[Constrain] = StringEnumProperty( VALID_CONSTRAIN, "none" ) constrain_y: StringEnumProperty[Constrain] = StringEnumProperty( VALID_CONSTRAIN, "none" ) text_wrap: StringEnumProperty[TextWrap] = StringEnumProperty( VALID_TEXT_WRAP, "wrap" ) text_overflow: StringEnumProperty[TextOverflow] = StringEnumProperty( VALID_TEXT_OVERFLOW, "fold" ) expand: StringEnumProperty[Expand] = StringEnumProperty(VALID_EXPAND, "greedy") line_pad = IntegerProperty(default=0, layout=True) """Padding added to left and right of lines.""" def __textual_animation__( self, attribute: str, start_value: object, value: object, start_time: float, duration: float | None, speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, level: AnimationLevel = "full", ) -> ScalarAnimation | None: if self.node is None: return None # Check we are animating a Scalar or Scalar offset if isinstance(start_value, (Scalar, ScalarOffset)): # If destination is a number, we can convert that to a scalar if isinstance(value, (int, float)): value = Scalar(value, Unit.CELLS, Unit.CELLS) # We can only animate to Scalar if not isinstance(value, (Scalar, ScalarOffset)): return None from textual.widget import Widget assert isinstance(self.node, Widget) return ScalarAnimation( self.node, self, start_time, attribute, value, duration=duration, speed=speed, easing=easing, on_complete=( partial(self.node.app.call_later, on_complete) if on_complete is not None else None ), level=level, ) return None def __eq__(self, styles: object) -> bool: """Check that Styles contains the same rules.""" if not isinstance(styles, StylesBase): return NotImplemented return self.get_rules() == styles.get_rules() def __getitem__(self, key: str) -> object: if key not in RULE_NAMES_SET: raise KeyError(key) return getattr(self, key) def get(self, key: str, default: object | None = None) -> object: return getattr(self, key) if key in RULE_NAMES_SET else default def __len__(self) -> int: return len(RULE_NAMES) def __iter__(self) -> Iterator[str]: return iter(RULE_NAMES) def __contains__(self, key: object) -> bool: return key in RULE_NAMES_SET def keys(self) -> Iterable[str]: return RULE_NAMES def values(self) -> Iterable[object]: for key in RULE_NAMES: yield getattr(self, key) def items(self) -> Iterable[tuple[str, object]]: for key in RULE_NAMES: yield (key, getattr(self, key)) @property def gutter(self) -> Spacing: """Get space around widget. Returns: Space around widget content. """ return self.padding + self.border.spacing @property def auto_dimensions(self) -> bool: """Check if width or height are set to 'auto'.""" has_rule = self.has_rule return (has_rule("width") and self.width.is_auto) or ( # type: ignore has_rule("height") and self.height.is_auto # type: ignore ) @property def is_relative_width(self, _relative_units={Unit.FRACTION, Unit.PERCENT}) -> bool: """Does the node have a relative width?""" width = self.width return width is not None and width.unit in _relative_units @property def is_relative_height(self, _relative_units={Unit.FRACTION, Unit.PERCENT}) -> bool: """Does the node have a relative width?""" height = self.height return height is not None and height.unit in _relative_units @property def is_auto_width(self, _auto=Unit.AUTO) -> bool: """Does the node have automatic width?""" width = self.width return width is not None and width.unit == _auto @property def is_auto_height(self, _auto=Unit.AUTO) -> bool: """Does the node have automatic height?""" height = self.height return height is not None and height.unit == _auto @property def is_dynamic_height( self, _dynamic_units={Unit.AUTO, Unit.FRACTION, Unit.PERCENT} ) -> bool: """Does the node have a dynamic (not fixed) height?""" height = self.height return height is not None and height.unit in _dynamic_units @property def is_docked(self) -> bool: """Is the node docked?""" return self.dock != "none" @property def is_split(self) -> bool: """Is the node split?""" return self.split != "none" def has_rule(self, rule_name: str) -> bool: """Check if a rule is set on this Styles object. Args: rule_name: Rule name. Returns: ``True`` if the rules is present, otherwise ``False``. """ raise NotImplementedError() def clear_rule(self, rule_name: str) -> bool: """Removes the rule from the Styles object, as if it had never been set. Args: rule_name: Rule name. Returns: ``True`` if a rule was cleared, or ``False`` if the rule is already not set. """ raise NotImplementedError() def get_rules(self) -> RulesMap: """Get the rules in a mapping. Returns: A TypedDict of the rules. """ raise NotImplementedError() def set_rule(self, rule_name: str, value: object | None) -> bool: """Set a rule. Args: rule_name: Rule name. value: New rule value. Returns: ``True`` if the rule changed, otherwise ``False``. """ raise NotImplementedError() def get_rule(self, rule_name: str, default: object = None) -> object: """Get an individual rule. Args: rule_name: Name of rule. default: Default if rule does not exists. Returns: Rule value or default. """ raise NotImplementedError() def refresh( self, *, layout: bool = False, children: bool = False, parent: bool = False, repaint: bool = True, ) -> None: """Mark the styles as requiring a refresh. Args: layout: Also require a layout. children: Also refresh children. parent: Also refresh the parent. repaint: Repaint the widgets. """ def reset(self) -> None: """Reset the rules to initial state.""" def merge(self, other: StylesBase) -> None: """Merge values from another Styles. Args: other: A Styles object. """ def merge_rules(self, rules: RulesMap) -> None: """Merge rules into Styles. Args: rules: A mapping of rules. """ def get_render_rules(self) -> RulesMap: """Get rules map with defaults.""" # Get a dictionary of rules, going through the properties rules = dict(zip(RULE_NAMES, _rule_getter(self))) return cast(RulesMap, rules) @classmethod def is_animatable(cls, rule: str) -> bool: """Check if a given rule may be animated. Args: rule: Name of the rule. Returns: ``True`` if the rule may be animated, otherwise ``False``. """ return rule in cls.ANIMATABLE @classmethod def parse( cls, css: str, read_from: CSSLocation, *, node: DOMNode | None = None ) -> Styles: """Parse CSS and return a Styles object. Args: css: Textual CSS. read_from: Location where the CSS was read from. node: Node to associate with the Styles. Returns: A Styles instance containing result of parsing CSS. """ from textual.css.parse import parse_declarations styles = parse_declarations(css, read_from) styles.node = node return styles def _get_transition(self, key: str) -> Transition | None: """Get a transition. Args: key: Transition key. Returns: Transition object or None it no transition exists. """ if key in self.ANIMATABLE: return self.transitions.get(key, None) else: return None def _align_width(self, width: int, parent_width: int) -> int: """Align the width dimension. Args: width: Width of the content. parent_width: Width of the parent container. Returns: An offset to add to the X coordinate. """ offset_x = 0 align_horizontal = self.align_horizontal if align_horizontal != "left": if align_horizontal == "center": offset_x = (parent_width - width) // 2 else: offset_x = parent_width - width return offset_x def _align_height(self, height: int, parent_height: int) -> int: """Align the height dimensions Args: height: Height of the content. parent_height: Height of the parent container. Returns: An offset to add to the Y coordinate. """ offset_y = 0 align_vertical = self.align_vertical if align_vertical != "top": if align_vertical == "middle": offset_y = (parent_height - height) // 2 else: offset_y = parent_height - height return offset_y def _align_size(self, child: tuple[int, int], parent: tuple[int, int]) -> Offset: """Align a size according to alignment rules. Args: child: The size of the child (width, height) parent: The size of the parent (width, height) Returns: Offset required to align the child. """ width, height = child parent_width, parent_height = parent return Offset( self._align_width(width, parent_width), self._align_height(height, parent_height), ) @property def partial_rich_style(self) -> Style: """Get the style properties associated with this node only (not including parents in the DOM). Returns: Rich Style object. """ style = Style( color=( self.color.rich_color if self.has_rule("color") and self.color.a > 0 else None ), bgcolor=( self.background.rich_color if self.has_rule("background") and self.background.a > 0 else None ), ) style += self.text_style return style @rich.repr.auto @dataclass class Styles(StylesBase): node: DOMNode | None = None _rules: RulesMap = field(default_factory=RulesMap) _updates: int = 0 important: set[str] = field(default_factory=set) def __post_init__(self) -> None: self.get_rule: Callable[[str, object], object] = self._rules.get # type: ignore[assignment] self.has_rule: Callable[[str], bool] = self._rules.__contains__ # type: ignore[assignment] def copy(self) -> Styles: """Get a copy of this Styles object.""" return Styles( node=self.node, _rules=self.get_rules(), important=self.important, ) def clear_rule(self, rule_name: str) -> bool: """Removes the rule from the Styles object, as if it had never been set. Args: rule_name: Rule name. Returns: ``True`` if a rule was cleared, or ``False`` if it was already not set. """ changed = self._rules.pop(rule_name, None) is not None # type: ignore if changed: self._updates += 1 return changed def get_rules(self) -> RulesMap: return self._rules.copy() def set_rule(self, rule: str, value: object | None) -> bool: """Set a rule. Args: rule: Rule name. value: New rule value. Returns: ``True`` if the rule changed, otherwise ``False``. """ if value is None: changed = self._rules.pop(rule, None) is not None # type: ignore if changed: self._updates += 1 return changed current = self._rules.get(rule) self._rules[rule] = value # type: ignore changed = current != value if changed: self._updates += 1 return changed def refresh( self, *, layout: bool = False, children: bool = False, parent: bool = False, repaint=True, ) -> None: node = self.node if node is None or not node._is_mounted: return if parent and node._parent is not None: node._parent.refresh(repaint=repaint) node.refresh(layout=layout) if children: for child in node.walk_children(with_self=False, reverse=True): child.refresh(layout=layout, repaint=repaint) def reset(self) -> None: """Reset the rules to initial state.""" self._updates += 1 self._rules.clear() # type: ignore def merge(self, other: StylesBase) -> None: """Merge values from another Styles. Args: other: A Styles object. """ self._updates += 1 self._rules.update(other.get_rules()) def merge_rules(self, rules: RulesMap) -> None: self._updates += 1 self._rules.update(rules) def extract_rules( self, specificity: Specificity3, is_default_rules: bool = False, tie_breaker: int = 0, ) -> list[tuple[str, Specificity6, Any]]: """Extract rules from Styles object, and apply !important css specificity as well as higher specificity of user CSS vs widget CSS. Args: specificity: A node specificity. is_default_rules: True if the rules we're extracting are default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. Returns: A list containing a tuple of , . """ is_important = self.important.__contains__ default_rules = 0 if is_default_rules else 1 rules: list[tuple[str, Specificity6, Any]] = [ ( rule_name, ( default_rules, 1 if is_important(rule_name) else 0, *specificity, tie_breaker, ), rule_value, ) for rule_name, rule_value in self._rules.items() ] return rules def __rich_repr__(self) -> rich.repr.Result: has_rule = self.has_rule for name in RULE_NAMES: if has_rule(name): yield name, getattr(self, name) if self.important: yield "important", self.important def _get_border_css_lines( self, rules: RulesMap, name: str ) -> Iterable[tuple[str, str]]: """Get pairs of strings containing , for border css declarations. Args: rules: A rules map. name: Name of rules (border or outline) Returns: An iterable of CSS declarations. """ has_rule = rules.__contains__ get_rule = rules.__getitem__ has_top = has_rule(f"{name}_top") has_right = has_rule(f"{name}_right") has_bottom = has_rule(f"{name}_bottom") has_left = has_rule(f"{name}_left") if not any((has_top, has_right, has_bottom, has_left)): # No border related rules return if all((has_top, has_right, has_bottom, has_left)): # All rules are set # See if we can set them with a single border: declaration top = get_rule(f"{name}_top") right = get_rule(f"{name}_right") bottom = get_rule(f"{name}_bottom") left = get_rule(f"{name}_left") if top == right and right == bottom and bottom == left: border_type, border_color = rules[f"{name}_top"] # type: ignore yield name, f"{border_type} {border_color.hex}" return # Check for edges if has_top: border_type, border_color = rules[f"{name}_top"] # type: ignore yield f"{name}-top", f"{border_type} {border_color.hex}" if has_right: border_type, border_color = rules[f"{name}_right"] # type: ignore yield f"{name}-right", f"{border_type} {border_color.hex}" if has_bottom: border_type, border_color = rules[f"{name}_bottom"] # type: ignore yield f"{name}-bottom", f"{border_type} {border_color.hex}" if has_left: border_type, border_color = rules[f"{name}_left"] # type: ignore yield f"{name}-left", f"{border_type} {border_color.hex}" @property def css_lines(self) -> list[str]: lines: list[str] = [] append = lines.append def append_declaration(name: str, value: str) -> None: if name in self.important: append(f"{name}: {value} !important;") else: append(f"{name}: {value};") rules = self.get_rules() get_rule = rules.get if "display" in rules: append_declaration("display", rules["display"]) if "visibility" in rules: append_declaration("visibility", rules["visibility"]) if "padding" in rules: append_declaration("padding", rules["padding"].css) if "margin" in rules: append_declaration("margin", rules["margin"].css) for name, rule in self._get_border_css_lines(rules, "border"): append_declaration(name, rule) for name, rule in self._get_border_css_lines(rules, "outline"): append_declaration(name, rule) if "offset" in rules: x, y = self.offset append_declaration("offset", f"{x} {y}") if "position" in rules: append_declaration("position", self.position) if "dock" in rules: append_declaration("dock", rules["dock"]) if "split" in rules: append_declaration("split", rules["split"]) if "layers" in rules: append_declaration("layers", " ".join(self.layers)) if "layer" in rules: append_declaration("layer", self.layer) if "layout" in rules: assert self.layout is not None append_declaration("layout", self.layout.name) if "color" in rules: append_declaration("color", self.color.hex) if "background" in rules: append_declaration("background", self.background.hex) if "background_tint" in rules: append_declaration("background-tint", self.background_tint.hex) if "text_style" in rules: append_declaration("text-style", str(get_rule("text_style"))) if "tint" in rules: append_declaration("tint", self.tint.css) if "overflow_x" in rules: append_declaration("overflow-x", self.overflow_x) if "overflow_y" in rules: append_declaration("overflow-y", self.overflow_y) if "scrollbar_color" in rules: append_declaration("scrollbar-color", self.scrollbar_color.css) if "scrollbar_color_hover" in rules: append_declaration("scrollbar-color-hover", self.scrollbar_color_hover.css) if "scrollbar_color_active" in rules: append_declaration( "scrollbar-color-active", self.scrollbar_color_active.css ) if "scrollbar_corner_color" in rules: append_declaration( "scrollbar-corner-color", self.scrollbar_corner_color.css ) if "scrollbar_background" in rules: append_declaration("scrollbar-background", self.scrollbar_background.css) if "scrollbar_background_hover" in rules: append_declaration( "scrollbar-background-hover", self.scrollbar_background_hover.css ) if "scrollbar_background_active" in rules: append_declaration( "scrollbar-background-active", self.scrollbar_background_active.css ) if "scrollbar_gutter" in rules: append_declaration("scrollbar-gutter", self.scrollbar_gutter) if "scrollbar_size" in rules: append_declaration( "scrollbar-size", f"{self.scrollbar_size_horizontal} {self.scrollbar_size_vertical}", ) else: if "scrollbar_size_horizontal" in rules: append_declaration( "scrollbar-size-horizontal", str(self.scrollbar_size_horizontal) ) if "scrollbar_size_vertical" in rules: 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) if "width" in rules: append_declaration("width", str(self.width)) if "height" in rules: append_declaration("height", str(self.height)) if "min_width" in rules: append_declaration("min-width", str(self.min_width)) if "min_height" in rules: append_declaration("min-height", str(self.min_height)) if "max_width" in rules: append_declaration("max-width", str(self.max_width)) if "max_height" in rules: append_declaration("max-height", str(self.max_height)) if "transitions" in rules: append_declaration( "transition", ", ".join( f"{name} {transition}" for name, transition in self.transitions.items() ), ) if "align_horizontal" in rules and "align_vertical" in rules: append_declaration( "align", f"{self.align_horizontal} {self.align_vertical}" ) elif "align_horizontal" in rules: append_declaration("align-horizontal", self.align_horizontal) elif "align_vertical" in rules: append_declaration("align-vertical", self.align_vertical) if "content_align_horizontal" in rules and "content_align_vertical" in rules: append_declaration( "content-align", f"{self.content_align_horizontal} {self.content_align_vertical}", ) elif "content_align_horizontal" in rules: append_declaration( "content-align-horizontal", self.content_align_horizontal ) elif "content_align_vertical" in rules: append_declaration("content-align-vertical", self.content_align_vertical) if "text_align" in rules: append_declaration("text-align", self.text_align) if "border_title_align" in rules: append_declaration("border-title-align", self.border_title_align) if "border_subtitle_align" in rules: append_declaration("border-subtitle-align", self.border_subtitle_align) if "opacity" in rules: append_declaration("opacity", str(self.opacity)) if "text_opacity" in rules: append_declaration("text-opacity", str(self.text_opacity)) if "grid_columns" in rules: append_declaration( "grid-columns", " ".join(str(scalar) for scalar in self.grid_columns or ()), ) if "grid_rows" in rules: append_declaration( "grid-rows", " ".join(str(scalar) for scalar in self.grid_rows or ()), ) if "grid_size_columns" in rules: append_declaration("grid-size-columns", str(self.grid_size_columns)) if "grid_size_rows" in rules: append_declaration("grid-size-rows", str(self.grid_size_rows)) if "grid_gutter_horizontal" in rules: append_declaration( "grid-gutter-horizontal", str(self.grid_gutter_horizontal) ) if "grid_gutter_vertical" in rules: append_declaration("grid-gutter-vertical", str(self.grid_gutter_vertical)) if "row_span" in rules: append_declaration("row-span", str(self.row_span)) if "column_span" in rules: append_declaration("column-span", str(self.column_span)) if "link_color" in rules: append_declaration("link-color", self.link_color.css) if "link_background" in rules: append_declaration("link-background", self.link_background.css) if "link_style" in rules: append_declaration("link-style", str(self.link_style)) if "link_color_hover" in rules: append_declaration("link-color-hover", self.link_color_hover.css) if "link_background_hover" in rules: append_declaration("link-background-hover", self.link_background_hover.css) if "link_style_hover" in rules: append_declaration("link-style-hover", str(self.link_style_hover)) if "border_title_color" in rules: append_declaration("title-color", self.border_title_color.css) if "border_title_background" in rules: append_declaration("title-background", self.border_title_background.css) if "border_title_style" in rules: append_declaration("title-text-style", str(self.border_title_style)) if "border_subtitle_color" in rules: append_declaration("subtitle-color", self.border_subtitle_color.css) if "border_subtitle_background" in rules: append_declaration( "subtitle-background", self.border_subtitle_background.css ) if "border_subtitle_text_style" in rules: append_declaration("subtitle-text-style", str(self.border_subtitle_style)) if "overlay" in rules: append_declaration("overlay", str(self.overlay)) if "constrain_x" in rules and "constrain_y" in rules: if self.constrain_x == self.constrain_y: append_declaration("constrain", self.constrain_x) else: append_declaration( "constrain", f"{self.constrain_x} {self.constrain_y}" ) elif "constrain_x" in rules: append_declaration("constrain-x", self.constrain_x) elif "constrain_y" in rules: append_declaration("constrain-y", self.constrain_y) if "keyline" in rules: keyline_type, keyline_color = self.keyline if keyline_type != "none": append_declaration("keyline", f"{keyline_type}, {keyline_color.css}") if "hatch" in rules: hatch_character, hatch_color = self.hatch append_declaration("hatch", f'"{hatch_character}" {hatch_color.css}') if "text_wrap" in rules: append_declaration("text-wrap", self.text_wrap) if "text_overflow" in rules: append_declaration("text-overflow", self.text_overflow) if "expand" in rules: append_declaration("expand", self.expand) if "line_pad" in rules: append_declaration("line-pad", str(self.line_pad)) lines.sort() return lines @property def css(self) -> str: return "\n".join(self.css_lines) @rich.repr.auto class RenderStyles(StylesBase): """Presents a combined view of two Styles object: a base Styles and inline Styles.""" def __init__(self, node: DOMNode, base: Styles, inline_styles: Styles) -> None: self.node = node self._base_styles = base self._inline_styles = inline_styles self._animate: BoundAnimator | None = None self._updates: int = 0 self._rich_style: tuple[int, Style] | None = None self._gutter: tuple[int, Spacing] | None = None def __eq__(self, other: object) -> bool: if isinstance(other, RenderStyles): return ( self._base_styles._rules == other._base_styles._rules and self._inline_styles._rules == other._inline_styles._rules ) return NotImplemented @property def _cache_key(self) -> int: """A cache key, that changes when any style is changed. Returns: An opaque integer. """ return self._updates + self._base_styles._updates + self._inline_styles._updates @property def base(self) -> Styles: """Quick access to base (css) style.""" return self._base_styles @property def inline(self) -> Styles: """Quick access to the inline styles.""" return self._inline_styles @property def rich_style(self) -> Style: """Get a Rich style for this Styles object.""" assert self.node is not None return self.node.rich_style @property def gutter(self) -> Spacing: """Get space around widget (padding + border) Returns: Space around widget content. """ # This is (surprisingly) a bit of a bottleneck if self._gutter is not None: cache_key, gutter = self._gutter if cache_key == self._cache_key: return gutter gutter = self.padding + self.border.spacing self._gutter = (self._cache_key, gutter) return gutter def animate( self, attribute: str, value: str | float | Animatable, *, final_value: object = ..., duration: float | None = None, speed: float | None = None, delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, level: AnimationLevel = "full", ) -> None: """Animate an attribute. Args: attribute: Name of the attribute to animate. value: The value to animate to. final_value: The final value of the animation. Defaults to `value` if not set. duration: The duration (in seconds) of the animation. speed: The speed of the animation. delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: assert self.node is not None self._animate = self.node.app.animator.bind(self) assert self._animate is not None self._animate( attribute, value, final_value=final_value, duration=duration, speed=speed, delay=delay, easing=easing, on_complete=on_complete, level=level, ) def __rich_repr__(self) -> rich.repr.Result: yield self.node for rule_name in RULE_NAMES: if self.has_rule(rule_name): yield rule_name, getattr(self, rule_name) def refresh( self, *, layout: bool = False, children: bool = False, parent: bool = False, repaint: bool = True, ) -> None: self._inline_styles.refresh( layout=layout, children=children, parent=parent, repaint=repaint ) def merge(self, other: StylesBase) -> None: """Merge values from another Styles. Args: other: A Styles object. """ self._inline_styles.merge(other) def merge_rules(self, rules: RulesMap) -> None: self._inline_styles.merge_rules(rules) self._updates += 1 def reset(self) -> None: """Reset the rules to initial state.""" self._inline_styles.reset() self._updates += 1 def has_rule(self, rule_name: str) -> bool: """Check if a rule has been set.""" return self._inline_styles.has_rule(rule_name) or self._base_styles.has_rule( rule_name ) def has_any_rules(self, *rule_names: str) -> bool: """Check if any of the supplied rules have been set. Args: rule_names: Number of rules. Returns: `True` if any of the supplied rules have been set, `False` if none have. """ inline_has_rule = self._inline_styles.has_rule base_has_rule = self._base_styles.has_rule return any(inline_has_rule(name) or base_has_rule(name) for name in rule_names) def set_rule(self, rule_name: str, value: object | None) -> bool: return self._inline_styles.set_rule(rule_name, value) def get_rule(self, rule_name: str, default: object = None) -> object: if self._inline_styles.has_rule(rule_name): return self._inline_styles.get_rule(rule_name, default) return self._base_styles.get_rule(rule_name, default) def clear_rule(self, rule_name: str) -> bool: """Clear a rule (from inline).""" return self._inline_styles.clear_rule(rule_name) def get_rules(self) -> RulesMap: """Get rules as a dictionary""" rules = {**self._base_styles._rules, **self._inline_styles._rules} return cast(RulesMap, rules) @property def css(self) -> str: """Get the CSS for the combined styles.""" styles = Styles() styles.merge(self._base_styles) styles.merge(self._inline_styles) combined_css = styles.css return combined_css