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 @@
+
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 @@
+
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