diff --git a/CHANGELOG.md b/CHANGELOG.md index 35395ba0ef..5ca26cb4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `background-tint` CSS rule https://github.com/Textualize/textual/pull/5117 +- Added `:first-of-type`, `:last-of-type`, `:odd`, and `:even` pseudo classes https://github.com/Textualize/textual/pull/5139 ## [0.83.0] - 2024-10-10 diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 187a7b015c..2e27db2ca9 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -330,10 +330,14 @@ Here are some other pseudo classes: - `:dark` Matches widgets in dark mode (where `App.dark == True`). - `:disabled` Matches widgets which are in a disabled state. - `:enabled` Matches widgets which are in an enabled state. +- `:even` Matches a widget at an evenly numbered position within its siblings. +- `:first-of-type` Matches a widget that is the first of its type amongst its siblings. - `:focus-within` Matches widgets with a focused child widget. - `:focus` Matches widgets which have input focus. - `:inline` Matches widgets when the app is running in inline mode. +- `:last-of-type` Matches a widget that is the last of its type amongst its siblings. - `:light` Matches widgets in dark mode (where `App.dark == False`). +- `:odd` Matches a widget at an oddly numbered position within its siblings. ## Combinators diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index 7fbd4751d6..dce4663281 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -196,23 +196,30 @@ def resolve_box_models( Returns: List of resolved box models. """ - margin_width, margin_height = margin - - fraction_width = Fraction(max(0, size.width - margin_width)) - fraction_height = Fraction(max(0, size.height - margin_height)) + margin_width, margin_height = margin + fraction_width = Fraction(size.width) + fraction_height = Fraction(size.height) + fraction_zero = Fraction(0) margin_size = size - margin + margins = [widget.styles.margin.totals for widget in widgets] + # Fixed box models box_models: list[BoxModel | None] = [ ( None if _dimension is not None and _dimension.is_fraction else widget._get_box_model( - size, viewport_size, fraction_width, fraction_height + size, + viewport_size, + max(fraction_zero, fraction_width - margin_width), + max(fraction_zero, fraction_height - margin_height), ) ) - for (_dimension, widget) in zip(dimensions, widgets) + for (_dimension, widget, (margin_width, margin_height)) in zip( + dimensions, widgets, margins + ) ] if None not in box_models: diff --git a/src/textual/app.py b/src/textual/app.py index 2fe446e62c..d66940aa30 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -493,6 +493,16 @@ class MyApp(App[None]): INLINE_PADDING: ClassVar[int] = 1 """Number of blank lines above an inline app.""" + _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App], bool]]] = { + "focus": lambda app: app.app_focus, + "blur": lambda app: not app.app_focus, + "dark": lambda app: app.dark, + "light": lambda app: not app.dark, + "inline": lambda app: app.is_inline, + "ansi": lambda app: app.ansi_color, + "nocolor": lambda app: app.no_color, + } # type: ignore[assignment] + title: Reactive[str] = Reactive("", compute=False) """The title of the app, displayed in the header.""" sub_title: Reactive[str] = Reactive("", compute=False) @@ -913,22 +923,6 @@ def _context(self) -> Generator[None, None, None]: active_message_pump.reset(message_pump_reset_token) active_app.reset(app_reset_token) - def get_pseudo_classes(self) -> Iterable[str]: - """Pseudo classes for a widget. - - Returns: - Names of the pseudo classes. - """ - app_theme = self.get_theme(self.theme) - yield "focus" if self.app_focus else "blur" - yield "dark" if app_theme and app_theme.dark else "light" - if self.is_inline: - yield "inline" - if self.ansi_color: - yield "ansi" - if self.no_color: - yield "nocolor" - def _watch_ansi_color(self, ansi_color: bool) -> None: """Enable or disable the truecolor filter when the reactive changes""" for filter in self._filters: @@ -3265,6 +3259,8 @@ def _register( widget_list = widgets apply_stylesheet = self.stylesheet.apply + new_widgets: list[Widget] = [] + add_new_widget = new_widgets.append for widget in widget_list: widget._closing = False widget._closed = False @@ -3272,10 +3268,12 @@ def _register( if not isinstance(widget, Widget): raise AppError(f"Can't register {widget!r}; expected a Widget instance") if widget not in self._registry: + add_new_widget(widget) self._register_child(parent, widget, before, after) if widget._nodes: self._register(widget, *widget._nodes, cache=cache) - apply_stylesheet(widget, cache=cache) + for widget in new_widgets: + apply_stylesheet(widget, cache=cache) if not self._running: # If the app is not running, prevent awaiting of the widget tasks diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 026e78be45..82af4e4912 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -72,6 +72,10 @@ "inline", "light", "nocolor", + "first-of-type", + "last-of-type", + "odd", + "even", } VALID_OVERLAY: Final = {"none", "screen"} VALID_CONSTRAIN: Final = {"inflect", "inside", "none"} diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 399d06b1fb..6ad76e44db 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -432,6 +432,15 @@ def _check_rule( if _check_selectors(selector_set.selectors, css_path_nodes): yield selector_set.specificity + # pseudo classes which iterate over many nodes + # these have the potential to be slow, and shouldn't be used in a cache key + EXPENSIVE_PSEUDO_CLASSES = { + "first-of-type", + "last-of_type", + "odd", + "even", + } + def apply( self, node: DOMNode, @@ -467,14 +476,18 @@ def apply( for rule in rules_map[name] } rules = list(filter(limit_rules.__contains__, reversed(self.rules))) - - node._has_hover_style = any("hover" in rule.pseudo_classes for rule in rules) - node._has_focus_within = any( - "focus-within" in rule.pseudo_classes for rule in rules + all_pseudo_classes = set().union(*[rule.pseudo_classes for rule in rules]) + node._has_hover_style = "hover" in all_pseudo_classes + node._has_focus_within = "focus-within" in all_pseudo_classes + node._has_order_style = not all_pseudo_classes.isdisjoint( + {"first-of-type", "last-of-type", "odd", "even"} ) - cache_key: tuple | None - if cache is not None: + cache_key: tuple | None = None + + if cache is not None and all_pseudo_classes.isdisjoint( + self.EXPENSIVE_PSEUDO_CLASSES + ): cache_key = ( node._parent, ( @@ -483,7 +496,7 @@ def apply( else (node._id if f"#{node._id}" in rules_map else None) ), node.classes, - node.pseudo_classes, + node._pseudo_classes_cache_key, node._css_type_name, ) cached_result: RulesMap | None = cache.get(cache_key) @@ -491,8 +504,6 @@ def apply( self.replace_rules(node, cached_result, animate=animate) self._process_component_classes(node) return - else: - cache_key = None _check_rule = self._check_rule css_path_nodes = node.css_path_nodes @@ -561,8 +572,7 @@ def apply( rule_value = getattr(_DEFAULT_STYLES, initial_rule_name) node_rules[initial_rule_name] = rule_value # type: ignore[literal-required] - if cache is not None: - assert cache_key is not None + if cache_key is not None: cache[cache_key] = node_rules self.replace_rules(node, node_rules, animate=animate) self._process_component_classes(node) diff --git a/src/textual/dom.py b/src/textual/dom.py index 4608a3681b..ef23e9fdda 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -180,6 +180,9 @@ class DOMNode(MessagePump): # Names of potential computed reactives _computes: ClassVar[frozenset[str]] + _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[object], bool]]] = {} + """Pseudo class checks.""" + def __init__( self, *, @@ -217,6 +220,8 @@ def __init__( ) self._has_hover_style: bool = False self._has_focus_within: bool = False + self._has_order_style: bool = False + """The node has an ordered dependent pseudo-style (`:odd`, `:even`, `:first-of-type`, `:last-of-type`)""" self._reactive_connect: ( dict[str, tuple[MessagePump, Reactive[object] | object]] | None ) = None @@ -1228,13 +1233,18 @@ def on_dark_change(old_value:bool, new_value:bool) -> None: """ _watch(self, obj, attribute_name, callback, init=init) - def get_pseudo_classes(self) -> Iterable[str]: - """Get any pseudo classes applicable to this Node, e.g. hover, focus. + def get_pseudo_classes(self) -> set[str]: + """Pseudo classes for a widget. Returns: - Iterable of strings, such as a generator. + Names of the pseudo classes. """ - return () + + return { + name + for name, check_class in self._PSEUDO_CLASSES.items() + if check_class(self) + } def reset_styles(self) -> None: """Reset styles back to their initial state.""" @@ -1658,7 +1668,10 @@ def has_pseudo_class(self, class_name: str) -> bool: Returns: `True` if the DOM node has the pseudo class, `False` if not. """ - return class_name in self.get_pseudo_classes() + try: + return self._PSEUDO_CLASSES[class_name](self) + except KeyError: + return False def has_pseudo_classes(self, class_names: set[str]) -> bool: """Check the node has all the given pseudo classes. @@ -1669,7 +1682,16 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool: Returns: `True` if all pseudo class names are present. """ - return class_names.issubset(self.get_pseudo_classes()) + PSEUDO_CLASSES = self._PSEUDO_CLASSES + try: + return all(PSEUDO_CLASSES[name](self) for name in class_names) + except KeyError: + return False + + @property + def _pseudo_classes_cache_key(self) -> tuple[int, ...]: + """A cache key used when updating a number of nodes from the stylesheet.""" + return () def refresh( self, *, repaint: bool = True, layout: bool = False, recompose: bool = False diff --git a/src/textual/widget.py b/src/textual/widget.py index a311e8ab9f..585318919c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -14,6 +14,7 @@ from typing import ( TYPE_CHECKING, AsyncGenerator, + Callable, ClassVar, Collection, Generator, @@ -367,6 +368,25 @@ class Widget(DOMNode): # Default sort order, incremented by constructor _sort_order: ClassVar[int] = 0 + _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[Widget], bool]]] = { + "hover": lambda widget: widget.mouse_hover, + "focus": lambda widget: widget.has_focus, + "blur": lambda widget: not widget.has_focus, + "can-focus": lambda widget: widget.can_focus, + "disabled": lambda widget: widget.is_disabled, + "enabled": lambda widget: not widget.is_disabled, + "dark": lambda widget: widget.app.dark, + "light": lambda widget: not widget.app.dark, + "focus-within": lambda widget: widget.has_focus_within, + "inline": lambda widget: widget.app.is_inline, + "ansi": lambda widget: widget.app.ansi_color, + "nocolor": lambda widget: widget.app.no_color, + "first-of-type": lambda widget: widget.first_of_type, + "last-of-type": lambda widget: widget.last_of_type, + "odd": lambda widget: widget.is_odd, + "even": lambda widget: widget.is_even, + } # type: ignore[assignment] + def __init__( self, *children: Widget, @@ -464,6 +484,13 @@ def __init__( self._cover_widget: Widget | None = None """Widget to render over this widget (used by loading indicator).""" + self._first_of_type: tuple[int, bool] = (-1, False) + """Used to cache :first-of-type pseudoclass state.""" + self._last_of_type: tuple[int, bool] = (-1, False) + """Used to cache :last-of-type pseudoclass state.""" + self._odd: tuple[int, bool] = (-1, False) + """Used to cache :odd pseudoclass state.""" + @property def is_mounted(self) -> bool: """Check if this widget is mounted.""" @@ -701,6 +728,83 @@ def compose_add_child(self, widget: Widget) -> None: _rich_traceback_omit = True self._pending_children.append(widget) + @property + def is_disabled(self) -> bool: + """Is the widget disabled either because `disabled=True` or an ancestor has `disabled=True`.""" + node: MessagePump | None = self + while isinstance(node, Widget): + if node.disabled: + return True + node = node._parent + return False + + @property + def has_focus_within(self) -> bool: + """Are any descendants focused?""" + try: + focused = self.screen.focused + except NoScreen: + return False + node = focused + while node is not None: + if node is self: + return True + node = node._parent + return False + + @property + def first_of_type(self) -> bool: + """Is this the first widget of its type in its siblings?""" + parent = self.parent + if parent is None: + return True + # This pseudo classes only changes when the parent's nodes._updates changes + if parent._nodes._updates == self._first_of_type[0]: + return self._first_of_type[1] + widget_type = type(self) + for node in parent._nodes: + if isinstance(node, widget_type): + self._first_of_type = (parent._nodes._updates, node is self) + return self._first_of_type[1] + return False + + @property + def last_of_type(self) -> bool: + """Is this the last widget of its type in its siblings?""" + parent = self.parent + if parent is None: + return True + # This pseudo classes only changes when the parent's nodes._updates changes + if parent._nodes._updates == self._last_of_type[0]: + return self._last_of_type[1] + widget_type = type(self) + for node in reversed(parent._nodes): + if isinstance(node, widget_type): + self._last_of_type = (parent._nodes._updates, node is self) + return self._last_of_type[1] + return False + + @property + def is_odd(self) -> bool: + """Is this widget at an oddly numbered position within its siblings?""" + parent = self.parent + if parent is None: + return True + # This pseudo classes only changes when the parent's nodes._updates changes + if parent._nodes._updates == self._odd[0]: + return self._odd[1] + try: + is_odd = parent._nodes.index(self) % 2 == 0 + self._odd = (parent._nodes._updates, is_odd) + return is_odd + except ValueError: + return False + + @property + def is_even(self) -> bool: + """Is this widget at an evenly numbered position within its siblings?""" + return not self.is_odd + def __enter__(self) -> Self: """Use as context manager when composing.""" self.app._compose_stacks[-1].append(self) @@ -1063,8 +1167,16 @@ def mount( parent, *widgets, before=insert_before, after=insert_after ) + def update_styles(children: Iterable[DOMNode]) -> None: + """Update order related CSS""" + for child in children: + if child._has_order_style: + child._update_styles() + + self.call_later(update_styles, list(self.children)) await_mount = AwaitMount(self, mounted) self.call_next(await_mount) + return await_mount def mount_all( @@ -3233,51 +3345,6 @@ def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]] scrollbar.window_size = window_region.width yield scrollbar, scrollbar_region - def get_pseudo_classes(self) -> Iterable[str]: - """Pseudo classes for a widget. - - Returns: - Names of the pseudo classes. - """ - app = self.app - if self.mouse_hover: - yield "hover" - if self.has_focus: - yield "focus" - else: - yield "blur" - if self.can_focus: - yield "can-focus" - node: MessagePump | None = self - while isinstance(node, Widget): - if node.disabled: - yield "disabled" - break - node = node._parent - else: - yield "enabled" - try: - focused = self.screen.focused - except NoScreen: - pass - else: - app_theme = app.get_theme(app.theme) - yield "dark" if app_theme.dark else "light" - if focused: - node = focused - while node is not None: - if node is self: - yield "focus-within" - break - node = node._parent - - if app.is_inline: - yield "inline" - if app.ansi_color: - yield "ansi" - if app.no_color: - yield "nocolor" - def get_pseudo_class_state(self) -> PseudoClasses: """Get an object describing whether each pseudo class is present on this object or not. @@ -3299,6 +3366,15 @@ def get_pseudo_class_state(self) -> PseudoClasses: ) return pseudo_classes + @property + def _pseudo_classes_cache_key(self) -> tuple[int, ...]: + """A cache key that changes when the pseudo-classes change.""" + return ( + self.mouse_hover, + self.has_focus, + self.is_disabled, + ) + def _get_rich_justify(self) -> JustifyMethod | None: """Get the justify method that may be passed to a Rich renderable.""" text_justify: JustifyMethod | None = None diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_fr_and_margin.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_fr_and_margin.svg new file mode 100644 index 0000000000..82abecfc79 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_fr_and_margin.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FRApp + + + + + + + + + + No margin - should extend to left and right                                      + + +A margin of 2, should be 2 cells around the text                             + + + + +A margin of 4, should be 4 cells around the text                         + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_pseudo_classes.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_pseudo_classes.svg new file mode 100644 index 0000000000..013fc91c3f --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_pseudo_classes.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PSApp + + + + + + + + + + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +Item 1 + +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +Item 2 + + + +Item 3 + + + +Item 4 + + + +Item 5 + + + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +HELLO + +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index b772dec15b..969a62e5b8 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -8,7 +8,7 @@ from textual import events, on from textual.app import App, ComposeResult from textual.binding import Binding, Keymap -from textual.containers import Center, Grid, Middle, Vertical, VerticalScroll +from textual.containers import Center, Container, Grid, Middle, Vertical, VerticalScroll from textual.pilot import Pilot from textual.renderables.gradient import LinearGradient from textual.screen import ModalScreen, Screen @@ -2336,3 +2336,72 @@ def compose(self) -> ComposeResult: yield Label("100%") assert snap_compare(BackgroundTintApp()) + + +def test_fr_and_margin(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/5116""" + + # Check margins can be independently applied to widgets with fr unites + + class FRApp(App): + CSS = """ + #first-container { + background: green; + height: auto; + } + + #second-container { + margin: 2; + background: red; + height: auto; + } + + #third-container { + margin: 4; + background: blue; + height: auto; + } + """ + + def compose(self) -> ComposeResult: + with Container(id="first-container"): + yield Label("No margin - should extend to left and right") + + with Container(id="second-container"): + yield Label("A margin of 2, should be 2 cells around the text") + + with Container(id="third-container"): + yield Label("A margin of 4, should be 4 cells around the text") + + assert snap_compare(FRApp()) + + +def test_pseudo_classes(snap_compare): + """Test pseudo classes added in https://github.com/Textualize/textual/pull/5139 + + You should see 6 bars, with alternating green and red backgrounds. + + The first bar should have a red border. + + The last bar should have a green border. + + """ + + class PSApp(App): + CSS = """ + Label { width: 1fr; height: 1fr; } + Label:first-of-type { border:heavy red; } + Label:last-of-type { border:heavy green; } + Label:odd {background: $success 20%; } + Label:even {background: $error 20%; } + """ + + def compose(self) -> ComposeResult: + for item_number in range(5): + yield Label(f"Item {item_number+1}") + + def on_mount(self) -> None: + # Mounting a new widget should updated previous widgets, as the last of type has changed + self.mount(Label("HELLO")) + + assert snap_compare(PSApp()) diff --git a/tests/test_app.py b/tests/test_app.py index 69cd8787f5..697fcbb172 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -37,6 +37,9 @@ async def test_hover_update_styles(): "can-focus", "dark", "enabled", + "first-of-type", + "last-of-type", + "even", } # Take note of the initial background colour @@ -50,6 +53,9 @@ async def test_hover_update_styles(): "dark", "enabled", "hover", + "first-of-type", + "last-of-type", + "even", } assert button.styles.background != initial_background diff --git a/tests/test_widget.py b/tests/test_widget.py index 22e784b3b2..8e32ca7726 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -594,3 +594,38 @@ def test_lazy_loading() -> None: assert not hasattr(widgets, "foo") assert not hasattr(widgets, "bar") assert hasattr(widgets, "Label") + + +async def test_of_type() -> None: + class MyApp(App): + def compose(self) -> ComposeResult: + for ordinal in range(5): + yield Label(f"Item {ordinal}") + + app = MyApp() + async with app.run_test(): + labels = list(app.query(Label)) + assert labels[0].first_of_type + assert not labels[0].last_of_type + assert labels[0].is_odd + assert not labels[0].is_even + + assert not labels[1].first_of_type + assert not labels[1].last_of_type + assert not labels[1].is_odd + assert labels[1].is_even + + assert not labels[2].first_of_type + assert not labels[2].last_of_type + assert labels[2].is_odd + assert not labels[2].is_even + + assert not labels[3].first_of_type + assert not labels[3].last_of_type + assert not labels[3].is_odd + assert labels[3].is_even + + assert not labels[4].first_of_type + assert labels[4].last_of_type + assert labels[4].is_odd + assert not labels[4].is_even