Skip to content

Commit 7e5f5a9

Browse files
committed
Support @include directives in QSS templates
Allow a .qss.in template to pull in another with @include "nav.qss.in", so styling can be split per GUI area instead of living in one file. Includes are resolved before token substitution, so included files use {{token}} placeholders exactly like the base template. Includes may nest; circular references raise QssProcessingError instead of recursing until the stack is exhausted.
1 parent 3f78c6d commit 7e5f5a9

2 files changed

Lines changed: 107 additions & 1 deletion

File tree

src/ert/gui/theme_manager/qss_processing.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .theme_utils import ColorTheme, read_theming_resource
77

88
_TOKEN_PATTERN = re.compile(r"\{\{([a-z0-9\-]+)\}\}")
9+
_INCLUDE_PATTERN = re.compile(r'^@include\s+"(.+?)"', re.MULTILINE)
910

1011
_BASE_TEMPLATE = "base"
1112

@@ -22,6 +23,27 @@ def read_qss_stylesheet_file(template_name: str) -> str:
2223
)
2324

2425

26+
def resolve_includes(template: str, *, _seen: frozenset[str] | None = None) -> str:
27+
"""Replace ``@include "file.qss.in"`` lines with the file's content.
28+
29+
Supports recursive includes and detects circular references.
30+
"""
31+
if _seen is None:
32+
_seen = frozenset()
33+
34+
def _replacer(match: re.Match[str]) -> str:
35+
filename = match.group(1)
36+
stem = filename.removesuffix(".qss.in")
37+
if stem in _seen:
38+
raise QssProcessingError(
39+
f"Circular @include detected: {stem} is already being processed"
40+
)
41+
content = read_qss_stylesheet_file(stem)
42+
return resolve_includes(content, _seen=_seen | {stem})
43+
44+
return _INCLUDE_PATTERN.sub(_replacer, template)
45+
46+
2547
def substitute_tokens(template: str, tokens: dict[str, str]) -> str:
2648
"""Replace all {{token-name}} placeholders with values from the token dict."""
2749
for name, value in tokens.items():
@@ -37,5 +59,6 @@ def substitute_tokens(template: str, tokens: dict[str, str]) -> str:
3759
def process_qss(theme: ColorTheme) -> str:
3860
"""Load tokens for the given theme and produce a fully-resolved QSS string."""
3961
raw = read_qss_stylesheet_file(_BASE_TEMPLATE)
62+
resolved = resolve_includes(raw)
4063
tokens = load_tokens(theme)
41-
return substitute_tokens(raw, tokens)
64+
return substitute_tokens(resolved, tokens)

tests/ert/unit_tests/gui/theme_manager/test_qss_processing.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
QssProcessingError,
88
process_qss,
99
read_qss_stylesheet_file,
10+
resolve_includes,
1011
substitute_tokens,
1112
)
1213
from ert.gui.theme_manager.theme_utils import ColorTheme
@@ -143,3 +144,85 @@ def test_that_qss_processing_error_is_an_exception() -> None:
143144
assert issubclass(QssProcessingError, Exception)
144145
err = QssProcessingError("boom")
145146
assert str(err) == "boom"
147+
148+
149+
def test_that_resolve_includes_replaces_include_directive_with_file_content(
150+
monkeypatch: pytest.MonkeyPatch,
151+
) -> None:
152+
monkeypatch.setattr(
153+
qss_mod,
154+
"read_theming_resource",
155+
lambda *, filename, resource_kind: "/* sidebar styles */",
156+
)
157+
template = 'before\n@include "sidebar.qss.in"\nafter'
158+
result = resolve_includes(template)
159+
assert result == "before\n/* sidebar styles */\nafter"
160+
161+
162+
def test_that_resolve_includes_handles_multiple_includes(
163+
monkeypatch: pytest.MonkeyPatch,
164+
) -> None:
165+
contents = {
166+
"qss_stylesheet/sidebar.qss.in": "sidebar",
167+
"qss_stylesheet/nav.qss.in": "nav",
168+
}
169+
monkeypatch.setattr(
170+
qss_mod,
171+
"read_theming_resource",
172+
lambda *, filename, resource_kind: contents[filename],
173+
)
174+
template = '@include "sidebar.qss.in"\n@include "nav.qss.in"'
175+
result = resolve_includes(template)
176+
assert result == "sidebar\nnav"
177+
178+
179+
def test_that_resolve_includes_supports_nested_includes(
180+
monkeypatch: pytest.MonkeyPatch,
181+
) -> None:
182+
contents = {
183+
"qss_stylesheet/outer.qss.in": '@include "inner.qss.in"',
184+
"qss_stylesheet/inner.qss.in": "inner-content",
185+
}
186+
monkeypatch.setattr(
187+
qss_mod,
188+
"read_theming_resource",
189+
lambda *, filename, resource_kind: contents[filename],
190+
)
191+
template = '@include "outer.qss.in"'
192+
result = resolve_includes(template)
193+
assert result == "inner-content"
194+
195+
196+
def test_that_resolve_includes_detects_circular_references(
197+
monkeypatch: pytest.MonkeyPatch,
198+
) -> None:
199+
contents = {
200+
"qss_stylesheet/a.qss.in": '@include "b.qss.in"',
201+
"qss_stylesheet/b.qss.in": '@include "a.qss.in"',
202+
}
203+
monkeypatch.setattr(
204+
qss_mod,
205+
"read_theming_resource",
206+
lambda *, filename, resource_kind: contents[filename],
207+
)
208+
template = '@include "a.qss.in"'
209+
with pytest.raises(QssProcessingError, match="Circular @include"):
210+
resolve_includes(template)
211+
212+
213+
def test_that_resolve_includes_returns_template_unchanged_when_no_includes() -> None:
214+
template = "QWidget { color: black; }"
215+
assert resolve_includes(template) == template
216+
217+
218+
def test_that_resolve_includes_raises_file_not_found_for_missing_include(
219+
monkeypatch: pytest.MonkeyPatch,
220+
) -> None:
221+
def _raise(*, filename: str, resource_kind: str) -> str:
222+
raise FileNotFoundError(f"not found: {resource_kind}")
223+
224+
monkeypatch.setattr(qss_mod, "read_theming_resource", _raise)
225+
226+
template = '@include "missing.qss.in"'
227+
with pytest.raises(FileNotFoundError):
228+
resolve_includes(template)

0 commit comments

Comments
 (0)