Skip to content

Commit 60b8074

Browse files
authored
fix for specificity (#3963)
* fix for specificity * changelog * docstrings
1 parent 1e1b023 commit 60b8074

File tree

8 files changed

+308
-6
lines changed

8 files changed

+308
-6
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [0.47.1] - 2023-01-05
9+
10+
### Fixed
11+
12+
- Fixed nested specificity https://github.com/Textualize/textual/pull/3963
13+
814
## [0.47.0] - 2024-01-04
915

1016
### Fixed
@@ -1568,6 +1574,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
15681574
- New handler system for messages that doesn't require inheritance
15691575
- Improved traceback handling
15701576

1577+
[0.47.1]: https://github.com/Textualize/textual/compare/v0.47.0...v0.47.1
15711578
[0.47.0]: https://github.com/Textualize/textual/compare/v0.46.0...v0.47.0
15721579
[0.46.0]: https://github.com/Textualize/textual/compare/v0.45.1...v0.46.0
15731580
[0.45.1]: https://github.com/Textualize/textual/compare/v0.45.0...v0.45.1

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "0.47.0"
3+
version = "0.47.1"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

src/textual/css/model.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,35 @@
1212
from .types import Specificity3
1313

1414
if TYPE_CHECKING:
15+
from typing_extensions import Self
16+
1517
from ..dom import DOMNode
1618

1719

1820
class SelectorType(Enum):
21+
"""Type of selector."""
22+
1923
UNIVERSAL = 1
24+
"""i.e. * operator"""
2025
TYPE = 2
26+
"""A CSS type, e.g Label"""
2127
CLASS = 3
28+
"""CSS class, e.g. .loaded"""
2229
ID = 4
30+
"""CSS ID, e.g. #main"""
2331
NESTED = 5
32+
"""Placeholder for nesting operator, i.e &"""
2433

2534

2635
class CombinatorType(Enum):
36+
"""Type of combinator."""
37+
2738
SAME = 1
39+
"""Selector is combined with previous selector"""
2840
DESCENDENT = 2
41+
"""Selector is a descendant of the previous selector"""
2942
CHILD = 3
43+
"""Selector is an immediate child of the previous selector"""
3044

3145

3246
@dataclass
@@ -116,6 +130,8 @@ def _check_id(self, node: DOMNode) -> bool:
116130

117131
@dataclass
118132
class Declaration:
133+
"""A single CSS declaration (not yet processed)."""
134+
119135
token: Token
120136
name: str
121137
tokens: list[Token] = field(default_factory=list)
@@ -124,6 +140,8 @@ class Declaration:
124140
@rich.repr.auto(angular=True)
125141
@dataclass
126142
class SelectorSet:
143+
"""A set of selectors associated with a rule set."""
144+
127145
selectors: list[Selector] = field(default_factory=list)
128146
specificity: Specificity3 = (0, 0, 0)
129147

@@ -141,6 +159,21 @@ def __rich_repr__(self) -> rich.repr.Result:
141159
yield selectors
142160
yield None, self.specificity
143161

162+
def _total_specificity(self) -> Self:
163+
"""Calculate total specificity of selectors.
164+
165+
Returns:
166+
Self.
167+
"""
168+
id_total = class_total = type_total = 0
169+
for selector in self.selectors:
170+
_id, _class, _type = selector.specificity
171+
id_total += _id
172+
class_total += _class
173+
type_total += _type
174+
self.specificity = (id_total, class_total, type_total)
175+
return self
176+
144177
@classmethod
145178
def from_selectors(cls, selectors: list[list[Selector]]) -> Iterable[SelectorSet]:
146179
for selector_list in selectors:

src/textual/css/parse.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def _add_specificity(
4646
Returns:
4747
Combined specificity.
4848
"""
49+
4950
a1, b1, c1 = specificity1
5051
a2, b2, c2 = specificity2
5152
return (a1 + a2, b1 + b2, c1 + c2)
@@ -228,9 +229,8 @@ def combine_selectors(
228229
SelectorSet(
229230
combine_selectors(
230231
rule_selector, recursive_selectors.selectors
231-
),
232-
(recursive_selectors.specificity),
233-
)
232+
)
233+
)._total_specificity()
234234
for recursive_selectors in rule_set.selector_set
235235
],
236236
rule_set.styles,

src/textual/css/tokenizer.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,19 @@ def __rich__(self) -> RenderableType:
103103

104104

105105
class EOFError(TokenError):
106-
pass
106+
"""Indicates that the CSS ended prematurely."""
107107

108108

109109
@rich.repr.auto
110110
class Expect:
111+
"""Object that describes the format of tokens."""
112+
111113
def __init__(self, description: str, **tokens: str) -> None:
114+
"""Create Expect object.
115+
116+
Args:
117+
description: Description of this class of tokens, used in errors.
118+
"""
112119
self.description = f"Expected {description}"
113120
self.names = list(tokens.keys())
114121
self.regexes = list(tokens.values())
@@ -190,14 +197,35 @@ def __rich_repr__(self) -> rich.repr.Result:
190197

191198

192199
class Tokenizer:
200+
"""Tokenizes Textual CSS."""
201+
193202
def __init__(self, text: str, read_from: CSSLocation = ("", "")) -> None:
203+
"""Initialize the tokenizer.
204+
205+
Args:
206+
text: String containing CSS.
207+
read_from: Information regarding where the CSS was read from.
208+
"""
194209
self.read_from = read_from
195210
self.code = text
196211
self.lines = text.splitlines(keepends=True)
197212
self.line_no = 0
198213
self.col_no = 0
199214

200215
def get_token(self, expect: Expect) -> Token:
216+
"""Get the next token.
217+
218+
Args:
219+
expect: Expect object which describes which tokens may be read.
220+
221+
Raises:
222+
EOFError: If there is an unexpected end of file.
223+
TokenError: If there is an error with the token.
224+
225+
Returns:
226+
A new Token.
227+
"""
228+
201229
line_no = self.line_no
202230
col_no = self.col_no
203231
if line_no >= len(self.lines):
@@ -220,7 +248,6 @@ def get_token(self, expect: Expect) -> Token:
220248
line = self.lines[line_no]
221249
match = expect.match(line, col_no)
222250
if match is None:
223-
expected = friendly_list(" ".join(name.split("_")) for name in expect.names)
224251
raise TokenError(
225252
self.read_from,
226253
self.code,
@@ -278,6 +305,17 @@ def get_token(self, expect: Expect) -> Token:
278305
return token
279306

280307
def skip_to(self, expect: Expect) -> Token:
308+
"""Skip tokens.
309+
310+
Args:
311+
expect: Expect object describing the expected token.
312+
313+
Raises:
314+
EOFError: If end of file is reached.
315+
316+
Returns:
317+
A new token.
318+
"""
281319
line_no = self.line_no
282320
col_no = self.col_no
283321

tests/snapshot_tests/__snapshots__/test_snapshots.ambr

Lines changed: 158 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from textual.app import App, ComposeResult
2+
from textual.widgets import Static
3+
4+
5+
class BaseTester(Static, can_focus=True):
6+
DEFAULT_CSS = """
7+
BaseTester:focus {
8+
background: yellow;
9+
border: thick magenta;
10+
}
11+
"""
12+
13+
14+
class NonNestedCSS(BaseTester):
15+
DEFAULT_CSS = """
16+
NonNestedCSS {
17+
width: 1fr;
18+
height: 1fr;
19+
background: red 10%;
20+
border: blank;
21+
}
22+
23+
NonNestedCSS:focus {
24+
background: red 20%;
25+
border: round red;
26+
}
27+
"""
28+
29+
30+
class NestedCSS(BaseTester):
31+
DEFAULT_CSS = """
32+
NestedCSS {
33+
width: 1fr;
34+
height: 1fr;
35+
background: green 10%;
36+
border: blank;
37+
38+
&:focus {
39+
background: green 20%;
40+
border: round green;
41+
}
42+
}
43+
"""
44+
45+
46+
class NestedPseudoClassesApp(App[None]):
47+
AUTO_FOCUS = "NestedCSS"
48+
49+
CSS = """
50+
Screen {
51+
layout: horizontal;
52+
}
53+
"""
54+
55+
def compose(self) -> ComposeResult:
56+
yield NonNestedCSS("This isn't using nested CSS")
57+
yield NestedCSS("This is using nested CSS")
58+
59+
60+
if __name__ == "__main__":
61+
NestedPseudoClassesApp().run()

tests/snapshot_tests/test_snapshots.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,3 +974,8 @@ def test_tree_clearing_and_expansion(snap_compare):
974974
# https://github.com/Textualize/textual/issues/3557
975975
assert snap_compare(SNAPSHOT_APPS_DIR / "tree_clearing.py")
976976

977+
978+
def test_nested_specificity(snap_compare):
979+
"""Test specificity of nested rules is working."""
980+
# https://github.com/Textualize/textual/issues/3961
981+
assert snap_compare(SNAPSHOT_APPS_DIR / "nested_specificity.py")

0 commit comments

Comments
 (0)