mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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 <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:
19
docs/examples/guide/css/nesting01.py
Normal file
19
docs/examples/guide/css/nesting01.py
Normal 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()
|
||||
24
docs/examples/guide/css/nesting01.tcss
Normal file
24
docs/examples/guide/css/nesting01.tcss
Normal 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;
|
||||
}
|
||||
19
docs/examples/guide/css/nesting02.py
Normal file
19
docs/examples/guide/css/nesting02.py
Normal 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()
|
||||
24
docs/examples/guide/css/nesting02.tcss
Normal file
24
docs/examples/guide/css/nesting02.tcss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -9,8 +9,7 @@ Input {
|
||||
|
||||
#results {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#results-container {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
65
tests/css/test_nested_css.py
Normal file
65
tests/css/test_nested_css.py
Normal 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", "")))
|
||||
Reference in New Issue
Block a user