* 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 <5621605+rodrigogiraoserrao@users.noreply.github.com>
Co-authored-by: Dave Pearson <davep@davep.org>

* highlighting

* wording

* wording

* check errors

* type checking:

* extra errors

* extra test [skip ci]

---------

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
Co-authored-by: Dave Pearson <davep@davep.org>
This commit is contained in:
Will McGugan
2024-01-04 15:07:43 +00:00
committed by GitHub
parent e5f223156f
commit b2fe0a76c2
14 changed files with 522 additions and 156 deletions

View File

@@ -0,0 +1,19 @@
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
class NestingDemo(App):
"""App that doesn't have nested CSS."""
CSS_PATH = "nesting01.tcss"
def compose(self) -> ComposeResult:
with Horizontal(id="questions"):
yield Static("Yes", classes="button affirmative")
yield Static("No", classes="button negative")
if __name__ == "__main__":
app = NestingDemo()
app.run()

View File

@@ -0,0 +1,24 @@
/* Style the container */
#questions {
border: heavy $primary;
align: center middle;
}
/* Style all buttons */
#questions .button {
width: 1fr;
padding: 1 2;
margin: 1 2;
text-align: center;
border: heavy $panel;
}
/* Style the Yes button */
#questions .button.affirmative {
border: heavy $success;
}
/* Style the No button */
#questions .button.negative {
border: heavy $error;
}

View File

@@ -0,0 +1,19 @@
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
class NestingDemo(App):
"""App with nested CSS."""
CSS_PATH = "nesting02.tcss"
def compose(self) -> ComposeResult:
with Horizontal(id="questions"):
yield Static("Yes", classes="button affirmative")
yield Static("No", classes="button negative")
if __name__ == "__main__":
app = NestingDemo()
app.run()

View File

@@ -0,0 +1,24 @@
/* Style the container */
#questions {
border: heavy $primary;
align: center middle;
/* Style all buttons */
.button {
width: 1fr;
padding: 1 2;
margin: 1 2;
text-align: center;
border: heavy $panel;
/* Style the Yes button */
&.affirmative {
border: heavy $success;
}
/* Style the No button */
&.negative {
border: heavy $error;
}
}
}

View File

@@ -468,3 +468,93 @@ For instance, if we have a widget with a (CSS) class called `dialog`, we could r
Note that `initial` will set the value back to the value defined in any [default css](./widgets.md#default-css).
If you use `initial` within default css, it will treat the rule as completely unstyled.
## Nesting CSS
!!! tip "Added in version 0.47.0"
CSS rule sets may be *nested*, i.e. they can contain other rule sets.
When a rule set occurs within an existing rule set, it inherits the selector from the enclosing rule set.
Let's put this into practical terms.
The following example will display two boxes containing the text "Yes" and "No" respectively.
These could eventually form the basis for buttons, but for this demonstration we are only interested in the CSS.
=== "nesting01.tcss (no nesting)"
```css
--8<-- "docs/examples/guide/css/nesting01.tcss"
```
=== "nesting01.py"
```python
--8<-- "docs/examples/guide/css/nesting01.py"
```
=== "Output"
```{.textual path="docs/examples/guide/css/nesting01.py"}
```
The CSS is quite straightforward; there is one rule for the container, one for all buttons, and one rule for each of the buttons.
However it is easy to imagine this stylesheet growing more rules as we add features.
Nesting allows us to group rule sets which have common selectors.
In the example above, the rules all start with `#questions`.
When we see a common prefix on the selectors, this is a good indication that we can use nesting.
The following produces identical results to the previous example, but adds nesting of the rules.
=== "nesting02.tcss (with nesting)"
```css
--8<-- "docs/examples/guide/css/nesting02.tcss"
```
=== "nesting02.py"
```python
--8<-- "docs/examples/guide/css/nesting02.py"
```
=== "Output"
```{.textual path="docs/examples/guide/css/nesting02.py"}
```
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`.
In the second example, the button rule selector is simply `.button`, but it is *within* the rule set with selector `#questions`.
The nesting means that the button rule set will inherit the selector from the outer rule set, so it is equivalent to `#questions .button`.
### Nesting selector
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.
You may have noticed that the rules for the button styles contain a syntax we haven't seen before.
The rule for the Yes button is `&.affirmative`.
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.
So `&.affirmative` in the example above, produces the equivalent of `#questions .button.affirmative` which selects a widget with both the `button` and `affirmative` classes.
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`.
For reference, lets see those two CSS files side-by-side:
=== "nesting01.tcss"
```css
--8<-- "docs/examples/guide/css/nesting01.tcss"
```
=== "nesting02.tcss"
```sass
--8<-- "docs/examples/guide/css/nesting02.tcss"
```
### Why use nesting?
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.

View File

@@ -9,8 +9,7 @@ Input {
#results {
width: 100%;
height: auto;
height: auto;
}
#results-container {

View File

@@ -20,6 +20,7 @@ class SelectorType(Enum):
TYPE = 2
CLASS = 3
ID = 4
NESTED = 5
class CombinatorType(Enum):
@@ -175,8 +176,7 @@ class RuleSet:
elif selector.combinator == CombinatorType.CHILD:
tokens.append(" > ")
tokens.append(selector.css)
for pseudo_class in selector.pseudo_classes:
tokens.append(f":{pseudo_class}")
return "".join(tokens).strip()
@property

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
from functools import lru_cache
from typing import Iterable, Iterator, NoReturn
@@ -29,14 +30,31 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = {
"selector_start_id": (SelectorType.ID, (1, 0, 0)),
"selector_universal": (SelectorType.UNIVERSAL, (0, 0, 0)),
"selector_start_universal": (SelectorType.UNIVERSAL, (0, 0, 0)),
"nested": (SelectorType.NESTED, (0, 0, 0)),
}
def _add_specificity(
specificity1: Specificity3, specificity2: Specificity3
) -> Specificity3:
"""Add specificity tuples together.
Args:
specificity1: Specificity triple.
specificity2: Specificity triple.
Returns:
Combined specificity.
"""
a1, b1, c1 = specificity1
a2, b2, c2 = specificity2
return (a1 + a2, b1 + b2, c1 + c2)
@lru_cache(maxsize=1024)
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
if not css_selectors.strip():
return ()
tokens = iter(tokenize(css_selectors, ("", "")))
get_selector = SELECTOR_MAP.get
@@ -46,10 +64,13 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
while True:
try:
token = next(tokens)
token = next(tokens, None)
except EOFError:
break
if token is None:
break
token_name = token.name
if token_name == "pseudo_class":
selectors[-1]._add_pseudo_class(token.value.lstrip(":"))
elif token_name == "whitespace":
@@ -143,14 +164,82 @@ def parse_rule_set(
rule_selectors.append(selectors[:])
declaration = Declaration(token, "")
errors: list[tuple[Token, str | HelpText]] = []
nested_rules: list[RuleSet] = []
while True:
token = next(tokens)
token_name = token.name
if token_name in ("whitespace", "declaration_end"):
continue
if token_name in {
"selector_start_id",
"selector_start_class",
"selector_start_universal",
"selector_start",
"nested",
}:
recursive_parse: list[RuleSet] = list(
parse_rule_set(
"",
tokens,
token,
is_default_rules=is_default_rules,
tie_breaker=tie_breaker,
)
)
def combine_selectors(
selectors1: list[Selector], selectors2: list[Selector]
) -> list[Selector]:
"""Combine lists of selectors together, processing any nesting.
Args:
selectors1: List of selectors.
selectors2: Second list of selectors.
Returns:
Combined selectors.
"""
if selectors2 and selectors2[0].type == SelectorType.NESTED:
final_selector = selectors1[-1]
nested_selector = selectors2[0]
merged_selector = dataclasses.replace(
final_selector,
pseudo_classes=list(
set(
final_selector.pseudo_classes
+ nested_selector.pseudo_classes
)
),
specificity=_add_specificity(
final_selector.specificity, nested_selector.specificity
),
)
return [*selectors1[:-1], merged_selector, *selectors2[1:]]
else:
return selectors1 + selectors2
for rule_selector in rule_selectors:
for rule_set in recursive_parse:
nested_rule_set = RuleSet(
[
SelectorSet(
combine_selectors(
rule_selector, recursive_selectors.selectors
),
(recursive_selectors.specificity),
)
for recursive_selectors in rule_set.selector_set
],
rule_set.styles,
rule_set.errors,
rule_set.is_default_rules,
rule_set.tie_breaker + tie_breaker,
)
nested_rules.append(nested_rule_set)
continue
if token_name == "declaration_name":
try:
styles_builder.add_declaration(declaration)
@@ -175,9 +264,14 @@ def parse_rule_set(
is_default_rules=is_default_rules,
tie_breaker=tie_breaker,
)
rule_set._post_parse()
yield rule_set
for nested_rule_set in nested_rules:
nested_rule_set._post_parse()
yield nested_rule_set
def parse_declarations(css: str, read_from: CSSLocation) -> Styles:
"""Parse declarations and return a Styles object.
@@ -270,7 +364,6 @@ def substitute_references(
attribute populated with information about where the tokens are being substituted to.
"""
variables: dict[str, list[Token]] = css_variables.copy() if css_variables else {}
iter_tokens = iter(tokens)
while True:
@@ -357,7 +450,6 @@ def parse(
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.
"""
reference_tokens = tokenize_values(variables) if variables is not None else {}
if variable_tokens:
reference_tokens.update(variable_tokens)

View File

@@ -248,7 +248,7 @@ class Stylesheet:
except TokenError:
raise
except Exception as error:
raise StylesheetError(f"failed to parse css; {error}")
raise StylesheetError(f"failed to parse css; {error}") from None
self._parse_cache[cache_key] = rules
return rules

View File

@@ -47,6 +47,7 @@ DECLARATION_VALUES = {
# in the CSS file. At this level we might expect to see selectors, comments,
# variable definitions etc.
expect_root_scope = Expect(
"selector or end of file",
whitespace=r"\s+",
comment_start=COMMENT_START,
comment_line=COMMENT_LINE,
@@ -55,11 +56,27 @@ expect_root_scope = Expect(
selector_start_universal=r"\*",
selector_start=IDENTIFIER,
variable_name=rf"{VARIABLE_REF}:",
declaration_set_end=r"\}",
).expect_eof(True)
expect_root_nested = Expect(
"selector or end of file",
whitespace=r"\s+",
comment_start=COMMENT_START,
comment_line=COMMENT_LINE,
selector_start_id=r"\#" + IDENTIFIER,
selector_start_class=r"\." + IDENTIFIER,
selector_start_universal=r"\*",
selector_start=IDENTIFIER,
variable_name=rf"{VARIABLE_REF}:",
declaration_set_end=r"\}",
nested=r"\&",
)
# After a variable declaration e.g. "$warning-text: TOKENS;"
# for tokenizing variable value ------^~~~~~~^
expect_variable_name_continue = Expect(
"variable value",
variable_value_end=r"\n|;",
whitespace=r"\s+",
comment_start=COMMENT_START,
@@ -68,12 +85,14 @@ expect_variable_name_continue = Expect(
).expect_eof(True)
expect_comment_end = Expect(
"comment end",
comment_end=re.escape("*/"),
)
# After we come across a selector in CSS e.g. ".my-class", we may
# find other selectors, pseudo-classes... e.g. ".my-class :hover"
expect_selector_continue = Expect(
"selector or {",
whitespace=r"\s+",
comment_start=COMMENT_START,
comment_line=COMMENT_LINE,
@@ -85,19 +104,28 @@ expect_selector_continue = Expect(
combinator_child=">",
new_selector=r",",
declaration_set_start=r"\{",
)
declaration_set_end=r"\}",
).expect_eof(True)
# A rule declaration e.g. "text: red;"
# ^---^
expect_declaration = Expect(
"rule or selector",
nested=r"\&",
whitespace=r"\s+",
comment_start=COMMENT_START,
comment_line=COMMENT_LINE,
declaration_name=r"[a-zA-Z_\-]+\:",
declaration_set_end=r"\}",
#
selector_start_id=r"\#" + IDENTIFIER,
selector_start_class=r"\." + IDENTIFIER,
selector_start_universal=r"\*",
selector_start=IDENTIFIER,
)
expect_declaration_solo = Expect(
"rule declaration",
whitespace=r"\s+",
comment_start=COMMENT_START,
comment_line=COMMENT_LINE,
@@ -108,6 +136,7 @@ expect_declaration_solo = Expect(
# The value(s)/content from a rule declaration e.g. "text: red;"
# ^---^
expect_declaration_content = Expect(
"rule value or end of declaration",
declaration_end=r";",
whitespace=r"\s+",
comment_start=COMMENT_START,
@@ -119,6 +148,7 @@ expect_declaration_content = Expect(
)
expect_declaration_content_solo = Expect(
"rule value or end of declaration",
declaration_end=r";",
whitespace=r"\s+",
comment_start=COMMENT_START,
@@ -156,7 +186,8 @@ class TokenizerState:
"declaration_set_start": expect_declaration,
"declaration_name": expect_declaration_content,
"declaration_end": expect_declaration,
"declaration_set_end": expect_root_scope,
"declaration_set_end": expect_root_nested,
"nested": expect_selector_continue,
}
def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]:
@@ -164,6 +195,7 @@ class TokenizerState:
expect = self.EXPECT
get_token = tokenizer.get_token
get_state = self.STATE_MAP.get
nest_level = 0
while True:
token = get_token(expect)
name = token.name
@@ -174,6 +206,13 @@ class TokenizerState:
continue
elif name == "eof":
break
elif name == "declaration_set_start":
nest_level += 1
elif name == "declaration_set_end":
nest_level -= 1
expect = expect_root_nested if nest_level else expect_root_scope
yield token
continue
expect = get_state(name, expect)
yield token

View File

@@ -106,8 +106,10 @@ class EOFError(TokenError):
pass
@rich.repr.auto
class Expect:
def __init__(self, **tokens: str) -> None:
def __init__(self, description: str, **tokens: str) -> None:
self.description = f"Expected {description}"
self.names = list(tokens.keys())
self.regexes = list(tokens.values())
self._regex = re.compile(
@@ -134,7 +136,7 @@ class ReferencedBy(NamedTuple):
code: str
@rich.repr.auto
@rich.repr.auto(angular=True)
class Token(NamedTuple):
name: str
value: str
@@ -213,18 +215,17 @@ class Tokenizer:
self.read_from,
self.code,
(line_no + 1, col_no + 1),
"Unexpected end of file",
"Unexpected end of file; did you forget a '}' ?",
)
line = self.lines[line_no]
match = expect.match(line, col_no)
if match is None:
expected = friendly_list(" ".join(name.split("_")) for name in expect.names)
message = f"Expected one of {expected}.; Did you forget a semicolon at the end of a line?"
raise TokenError(
self.read_from,
self.code,
(line_no + 1, col_no + 1),
message,
f"{expect.description} (found {line[col_no:].rstrip()!r}).; Did you forget a semicolon at the end of a line?",
)
iter_groups = iter(match.groups())
@@ -286,7 +287,7 @@ class Tokenizer:
self.read_from,
self.code,
(line_no, col_no),
"Unexpected end of file",
"Unexpected end of file; did you forget a '}' ?",
)
line = self.lines[line_no]
match = expect.search(line, col_no)

View File

@@ -45,109 +45,99 @@ class Button(Widget, can_focus=True):
text-align: center;
content-align: center middle;
text-style: bold;
}
Button:focus {
text-style: bold reverse;
}
&:focus {
text-style: bold reverse;
}
&:hover {
border-top: tall $panel;
background: $panel-darken-2;
color: $text;
}
&.-active {
background: $panel;
border-bottom: tall $panel-lighten-2;
border-top: tall $panel-darken-2;
tint: $background 30%;
}
Button:hover {
border-top: tall $panel;
background: $panel-darken-2;
color: $text;
}
&.-primary {
background: $primary;
color: $text;
border-top: tall $primary-lighten-3;
border-bottom: tall $primary-darken-3;
&:hover {
background: $primary-darken-2;
color: $text;
border-top: tall $primary;
}
Button.-active {
background: $panel;
border-bottom: tall $panel-lighten-2;
border-top: tall $panel-darken-2;
tint: $background 30%;
}
&.-active {
background: $primary;
border-bottom: tall $primary-lighten-3;
border-top: tall $primary-darken-3;
}
}
/* Primary variant */
Button.-primary {
background: $primary;
color: $text;
border-top: tall $primary-lighten-3;
border-bottom: tall $primary-darken-3;
&.-success {
background: $success;
color: $text;
border-top: tall $success-lighten-2;
border-bottom: tall $success-darken-3;
}
&:hover {
background: $success-darken-2;
color: $text;
border-top: tall $success;
}
Button.-primary:hover {
background: $primary-darken-2;
color: $text;
border-top: tall $primary;
}
&.-active {
background: $success;
border-bottom: tall $success-lighten-2;
border-top: tall $success-darken-2;
}
}
Button.-primary.-active {
background: $primary;
border-bottom: tall $primary-lighten-3;
border-top: tall $primary-darken-3;
}
&.-warning{
background: $warning;
color: $text;
border-top: tall $warning-lighten-2;
border-bottom: tall $warning-darken-3;
&:hover {
background: $warning-darken-2;
color: $text;
border-top: tall $warning;
}
/* Success variant */
Button.-success {
background: $success;
color: $text;
border-top: tall $success-lighten-2;
border-bottom: tall $success-darken-3;
}
&.-active {
background: $warning;
border-bottom: tall $warning-lighten-2;
border-top: tall $warning-darken-2;
}
}
Button.-success:hover {
background: $success-darken-2;
color: $text;
border-top: tall $success;
}
&.-error {
background: $error;
color: $text;
border-top: tall $error-lighten-2;
border-bottom: tall $error-darken-3;
Button.-success.-active {
background: $success;
border-bottom: tall $success-lighten-2;
border-top: tall $success-darken-2;
}
&:hover {
background: $error-darken-1;
color: $text;
border-top: tall $error;
}
&.-active {
background: $error;
border-bottom: tall $error-lighten-2;
border-top: tall $error-darken-2;
}
/* Warning variant */
Button.-warning {
background: $warning;
color: $text;
border-top: tall $warning-lighten-2;
border-bottom: tall $warning-darken-3;
}
}
Button.-warning:hover {
background: $warning-darken-2;
color: $text;
border-top: tall $warning;
}
Button.-warning.-active {
background: $warning;
border-bottom: tall $warning-lighten-2;
border-top: tall $warning-darken-2;
}
/* Error variant */
Button.-error {
background: $error;
color: $text;
border-top: tall $error-lighten-2;
border-bottom: tall $error-darken-3;
}
Button.-error:hover {
background: $error-darken-1;
color: $text;
border-top: tall $error;
}
Button.-error.-active {
background: $error;
border-bottom: tall $error-lighten-2;
border-top: tall $error-darken-2;
}
"""

View File

@@ -107,23 +107,26 @@ class SelectCurrent(Horizontal):
width: 100%;
height: auto;
padding: 0 2;
}
SelectCurrent Static#label {
width: 1fr;
height: auto;
color: $text-disabled;
background: transparent;
}
SelectCurrent.-has-value Static#label {
color: $text;
}
SelectCurrent .arrow {
box-sizing: content-box;
width: 1;
height: 1;
padding: 0 0 0 1;
color: $text-muted;
background: transparent;
Static#label {
width: 1fr;
height: auto;
color: $text-disabled;
background: transparent;
}
&.-has-value Static#label {
color: $text;
}
.arrow {
box-sizing: content-box;
width: 1;
height: 1;
padding: 0 0 0 1;
color: $text-muted;
background: transparent;
}
}
"""
@@ -196,40 +199,41 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
DEFAULT_CSS = """
Select {
height: auto;
& > SelectOverlay {
width: 1fr;
display: none;
height: auto;
max-height: 12;
overlay: screen;
constrain: y;
}
&:focus > SelectCurrent {
border: tall $accent;
}
.up-arrow {
display: none;
}
&.-expanded .down-arrow {
display: none;
}
&.-expanded .up-arrow {
display: block;
}
&.-expanded > SelectOverlay {
display: block;
}
&.-expanded > SelectCurrent {
border: tall $accent;
}
}
Select:focus > SelectCurrent {
border: tall $accent;
}
Select > SelectOverlay {
width: 1fr;
display: none;
height: auto;
max-height: 12;
overlay: screen;
constrain: y;
}
Select .up-arrow {
display:none;
}
Select.-expanded .down-arrow {
display:none;
}
Select.-expanded .up-arrow {
display: block;
}
Select.-expanded > SelectOverlay {
display: block;
}
Select.-expanded > SelectCurrent {
border: tall $accent;
}
"""
expanded: var[bool] = var(False, init=False)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import pytest
from textual.app import App, ComposeResult
from textual.color import Color
from textual.containers import Vertical
from textual.css.parse import parse
from textual.css.tokenizer import EOFError, TokenError
from textual.widgets import Label
class NestedApp(App):
CSS = """
Screen {
& > #foo {
background: red;
#egg {
background: green;
}
.paul {
background: blue;
}
&.jessica {
color: magenta;
}
}
}
"""
def compose(self) -> ComposeResult:
with Vertical(id="foo", classes="jessica"):
yield Label("Hello", id="egg")
yield Label("World", classes="paul")
async def test_nest_app():
"""Test nested CSS works as expected."""
app = NestedApp()
async with app.run_test():
assert app.query_one("#foo").styles.background == Color.parse("red")
assert app.query_one("#foo").styles.color == Color.parse("magenta")
assert app.query_one("#egg").styles.background == Color.parse("green")
assert app.query_one("#foo .paul").styles.background == Color.parse("blue")
@pytest.mark.parametrize(
("css", "exception"),
[
("Selector {", EOFError),
("Selector{ Foo {", EOFError),
("Selector{ Foo {}", EOFError),
("> {}", TokenError),
("&", TokenError),
("&&", TokenError),
("&.foo", TokenError),
("& .foo", TokenError),
("{", TokenError),
("*{", EOFError),
],
)
def test_parse_errors(css: str, exception: type[Exception]) -> None:
"""Check some CSS which should fail."""
with pytest.raises(exception):
list(parse("", css, ("foo", "")))