Skip to content

Commit b2fe0a7

Browse files
willmcguganrodrigogiraoserraodavep
authored
nested (#3946)
* nested * remove debug * patch scope * fix nested * docs * clarification * docstring * fix test * remove debug * copy * fix example * wording * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <[email protected]> Co-authored-by: Dave Pearson <[email protected]> * highlighting * wording * wording * check errors * type checking: * extra errors * extra test [skip ci] --------- Co-authored-by: Rodrigo Girão Serrão <[email protected]> Co-authored-by: Dave Pearson <[email protected]>
1 parent e5f2231 commit b2fe0a7

File tree

14 files changed

+528
-162
lines changed

14 files changed

+528
-162
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from textual.app import App, ComposeResult
2+
from textual.containers import Horizontal
3+
from textual.widgets import Static
4+
5+
6+
class NestingDemo(App):
7+
"""App that doesn't have nested CSS."""
8+
9+
CSS_PATH = "nesting01.tcss"
10+
11+
def compose(self) -> ComposeResult:
12+
with Horizontal(id="questions"):
13+
yield Static("Yes", classes="button affirmative")
14+
yield Static("No", classes="button negative")
15+
16+
17+
if __name__ == "__main__":
18+
app = NestingDemo()
19+
app.run()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/* Style the container */
2+
#questions {
3+
border: heavy $primary;
4+
align: center middle;
5+
}
6+
7+
/* Style all buttons */
8+
#questions .button {
9+
width: 1fr;
10+
padding: 1 2;
11+
margin: 1 2;
12+
text-align: center;
13+
border: heavy $panel;
14+
}
15+
16+
/* Style the Yes button */
17+
#questions .button.affirmative {
18+
border: heavy $success;
19+
}
20+
21+
/* Style the No button */
22+
#questions .button.negative {
23+
border: heavy $error;
24+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from textual.app import App, ComposeResult
2+
from textual.containers import Horizontal
3+
from textual.widgets import Static
4+
5+
6+
class NestingDemo(App):
7+
"""App with nested CSS."""
8+
9+
CSS_PATH = "nesting02.tcss"
10+
11+
def compose(self) -> ComposeResult:
12+
with Horizontal(id="questions"):
13+
yield Static("Yes", classes="button affirmative")
14+
yield Static("No", classes="button negative")
15+
16+
17+
if __name__ == "__main__":
18+
app = NestingDemo()
19+
app.run()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/* Style the container */
2+
#questions {
3+
border: heavy $primary;
4+
align: center middle;
5+
6+
/* Style all buttons */
7+
.button {
8+
width: 1fr;
9+
padding: 1 2;
10+
margin: 1 2;
11+
text-align: center;
12+
border: heavy $panel;
13+
14+
/* Style the Yes button */
15+
&.affirmative {
16+
border: heavy $success;
17+
}
18+
19+
/* Style the No button */
20+
&.negative {
21+
border: heavy $error;
22+
}
23+
}
24+
}

docs/guide/CSS.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,93 @@ For instance, if we have a widget with a (CSS) class called `dialog`, we could r
468468

469469
Note that `initial` will set the value back to the value defined in any [default css](./widgets.md#default-css).
470470
If you use `initial` within default css, it will treat the rule as completely unstyled.
471+
472+
473+
## Nesting CSS
474+
475+
!!! tip "Added in version 0.47.0"
476+
477+
CSS rule sets may be *nested*, i.e. they can contain other rule sets.
478+
When a rule set occurs within an existing rule set, it inherits the selector from the enclosing rule set.
479+
480+
Let's put this into practical terms.
481+
The following example will display two boxes containing the text "Yes" and "No" respectively.
482+
These could eventually form the basis for buttons, but for this demonstration we are only interested in the CSS.
483+
484+
=== "nesting01.tcss (no nesting)"
485+
486+
```css
487+
--8<-- "docs/examples/guide/css/nesting01.tcss"
488+
```
489+
490+
=== "nesting01.py"
491+
492+
```python
493+
--8<-- "docs/examples/guide/css/nesting01.py"
494+
```
495+
496+
=== "Output"
497+
498+
```{.textual path="docs/examples/guide/css/nesting01.py"}
499+
```
500+
501+
The CSS is quite straightforward; there is one rule for the container, one for all buttons, and one rule for each of the buttons.
502+
However it is easy to imagine this stylesheet growing more rules as we add features.
503+
504+
Nesting allows us to group rule sets which have common selectors.
505+
In the example above, the rules all start with `#questions`.
506+
When we see a common prefix on the selectors, this is a good indication that we can use nesting.
507+
508+
The following produces identical results to the previous example, but adds nesting of the rules.
509+
510+
=== "nesting02.tcss (with nesting)"
511+
512+
```css
513+
--8<-- "docs/examples/guide/css/nesting02.tcss"
514+
```
515+
516+
=== "nesting02.py"
517+
518+
```python
519+
--8<-- "docs/examples/guide/css/nesting02.py"
520+
```
521+
522+
=== "Output"
523+
524+
```{.textual path="docs/examples/guide/css/nesting02.py"}
525+
```
526+
527+
In the first example we had a rule set that began with the selector `#questions .button`, which would match any widget with a class called "button" that is inside a container with id `questions`.
528+
529+
In the second example, the button rule selector is simply `.button`, but it is *within* the rule set with selector `#questions`.
530+
The nesting means that the button rule set will inherit the selector from the outer rule set, so it is equivalent to `#questions .button`.
531+
532+
### Nesting selector
533+
534+
The two remaining rules are nested within the button rule, which means they will inherit their selectors from the button rule set *and* the outer `#questions` rule set.
535+
536+
You may have noticed that the rules for the button styles contain a syntax we haven't seen before.
537+
The rule for the Yes button is `&.affirmative`.
538+
The ampersand (`&`) is known as the *nesting selector* and it tells Textual that the selector should be combined with the selector from the outer rule set.
539+
540+
So `&.affirmative` in the example above, produces the equivalent of `#questions .button.affirmative` which selects a widget with both the `button` and `affirmative` classes.
541+
Without `&` it would be equivalent to `#questions .button .affirmative` (note the additional space) which would only match a widget with class `affirmative` inside a container with class `button`.
542+
543+
544+
For reference, lets see those two CSS files side-by-side:
545+
546+
=== "nesting01.tcss"
547+
548+
```css
549+
--8<-- "docs/examples/guide/css/nesting01.tcss"
550+
```
551+
552+
=== "nesting02.tcss"
553+
554+
```sass
555+
--8<-- "docs/examples/guide/css/nesting02.tcss"
556+
```
557+
558+
### Why use nesting?
559+
560+
There is no requirement to use nested CSS, but it can help to group related rule sets together (which makes it easier to edit). Nested CSS can also help you avoid some repetition in your selectors, i.e. in the nested CSS we only need to type `#questions` once, rather than four times in the non-nested CSS.

examples/dictionary.tcss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ Input {
99

1010
#results {
1111
width: 100%;
12-
height: auto;
13-
12+
height: auto;
1413
}
1514

1615
#results-container {

src/textual/css/model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class SelectorType(Enum):
2020
TYPE = 2
2121
CLASS = 3
2222
ID = 4
23+
NESTED = 5
2324

2425

2526
class CombinatorType(Enum):
@@ -175,8 +176,7 @@ def _selector_to_css(cls, selectors: list[Selector]) -> str:
175176
elif selector.combinator == CombinatorType.CHILD:
176177
tokens.append(" > ")
177178
tokens.append(selector.css)
178-
for pseudo_class in selector.pseudo_classes:
179-
tokens.append(f":{pseudo_class}")
179+
180180
return "".join(tokens).strip()
181181

182182
@property

src/textual/css/parse.py

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
from functools import lru_cache
45
from typing import Iterable, Iterator, NoReturn
56

@@ -29,14 +30,31 @@
2930
"selector_start_id": (SelectorType.ID, (1, 0, 0)),
3031
"selector_universal": (SelectorType.UNIVERSAL, (0, 0, 0)),
3132
"selector_start_universal": (SelectorType.UNIVERSAL, (0, 0, 0)),
33+
"nested": (SelectorType.NESTED, (0, 0, 0)),
3234
}
3335

3436

37+
def _add_specificity(
38+
specificity1: Specificity3, specificity2: Specificity3
39+
) -> Specificity3:
40+
"""Add specificity tuples together.
41+
42+
Args:
43+
specificity1: Specificity triple.
44+
specificity2: Specificity triple.
45+
46+
Returns:
47+
Combined specificity.
48+
"""
49+
a1, b1, c1 = specificity1
50+
a2, b2, c2 = specificity2
51+
return (a1 + a2, b1 + b2, c1 + c2)
52+
53+
3554
@lru_cache(maxsize=1024)
3655
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
3756
if not css_selectors.strip():
3857
return ()
39-
4058
tokens = iter(tokenize(css_selectors, ("", "")))
4159

4260
get_selector = SELECTOR_MAP.get
@@ -46,10 +64,13 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
4664

4765
while True:
4866
try:
49-
token = next(tokens)
67+
token = next(tokens, None)
5068
except EOFError:
5169
break
70+
if token is None:
71+
break
5272
token_name = token.name
73+
5374
if token_name == "pseudo_class":
5475
selectors[-1]._add_pseudo_class(token.value.lstrip(":"))
5576
elif token_name == "whitespace":
@@ -143,14 +164,82 @@ def parse_rule_set(
143164
rule_selectors.append(selectors[:])
144165

145166
declaration = Declaration(token, "")
146-
147167
errors: list[tuple[Token, str | HelpText]] = []
168+
nested_rules: list[RuleSet] = []
148169

149170
while True:
150171
token = next(tokens)
172+
151173
token_name = token.name
152174
if token_name in ("whitespace", "declaration_end"):
153175
continue
176+
if token_name in {
177+
"selector_start_id",
178+
"selector_start_class",
179+
"selector_start_universal",
180+
"selector_start",
181+
"nested",
182+
}:
183+
recursive_parse: list[RuleSet] = list(
184+
parse_rule_set(
185+
"",
186+
tokens,
187+
token,
188+
is_default_rules=is_default_rules,
189+
tie_breaker=tie_breaker,
190+
)
191+
)
192+
193+
def combine_selectors(
194+
selectors1: list[Selector], selectors2: list[Selector]
195+
) -> list[Selector]:
196+
"""Combine lists of selectors together, processing any nesting.
197+
198+
Args:
199+
selectors1: List of selectors.
200+
selectors2: Second list of selectors.
201+
202+
Returns:
203+
Combined selectors.
204+
"""
205+
if selectors2 and selectors2[0].type == SelectorType.NESTED:
206+
final_selector = selectors1[-1]
207+
nested_selector = selectors2[0]
208+
merged_selector = dataclasses.replace(
209+
final_selector,
210+
pseudo_classes=list(
211+
set(
212+
final_selector.pseudo_classes
213+
+ nested_selector.pseudo_classes
214+
)
215+
),
216+
specificity=_add_specificity(
217+
final_selector.specificity, nested_selector.specificity
218+
),
219+
)
220+
return [*selectors1[:-1], merged_selector, *selectors2[1:]]
221+
else:
222+
return selectors1 + selectors2
223+
224+
for rule_selector in rule_selectors:
225+
for rule_set in recursive_parse:
226+
nested_rule_set = RuleSet(
227+
[
228+
SelectorSet(
229+
combine_selectors(
230+
rule_selector, recursive_selectors.selectors
231+
),
232+
(recursive_selectors.specificity),
233+
)
234+
for recursive_selectors in rule_set.selector_set
235+
],
236+
rule_set.styles,
237+
rule_set.errors,
238+
rule_set.is_default_rules,
239+
rule_set.tie_breaker + tie_breaker,
240+
)
241+
nested_rules.append(nested_rule_set)
242+
continue
154243
if token_name == "declaration_name":
155244
try:
156245
styles_builder.add_declaration(declaration)
@@ -175,9 +264,14 @@ def parse_rule_set(
175264
is_default_rules=is_default_rules,
176265
tie_breaker=tie_breaker,
177266
)
267+
178268
rule_set._post_parse()
179269
yield rule_set
180270

271+
for nested_rule_set in nested_rules:
272+
nested_rule_set._post_parse()
273+
yield nested_rule_set
274+
181275

182276
def parse_declarations(css: str, read_from: CSSLocation) -> Styles:
183277
"""Parse declarations and return a Styles object.
@@ -270,7 +364,6 @@ def substitute_references(
270364
attribute populated with information about where the tokens are being substituted to.
271365
"""
272366
variables: dict[str, list[Token]] = css_variables.copy() if css_variables else {}
273-
274367
iter_tokens = iter(tokens)
275368

276369
while True:
@@ -357,7 +450,6 @@ def parse(
357450
is_default_rules: True if the rules we're extracting are
358451
default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
359452
"""
360-
361453
reference_tokens = tokenize_values(variables) if variables is not None else {}
362454
if variable_tokens:
363455
reference_tokens.update(variable_tokens)

src/textual/css/stylesheet.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ def _parse_rules(
248248
except TokenError:
249249
raise
250250
except Exception as error:
251-
raise StylesheetError(f"failed to parse css; {error}")
251+
raise StylesheetError(f"failed to parse css; {error}") from None
252252

253253
self._parse_cache[cache_key] = rules
254254
return rules

0 commit comments

Comments
 (0)