Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/ert/gui/theme_manager/qss_processing.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the pros/cons of having multiple .qss.in? Do we have enough components to that we need the added complexity of having between-qss communication?

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from .theme_utils import ColorTheme, read_theming_resource

_TOKEN_PATTERN = re.compile(r"\{\{([^{}\s]+)\}\}")
_INCLUDE_PATTERN = re.compile(
r'^[ \t]*@include[ \t]+"([^"\n]+\.qss\.in)"[ \t]*$', re.MULTILINE
)
_INCLUDE_LIKE_PATTERN = re.compile(r"^[ \t]*@include\b.*$", re.MULTILINE)

_BASE_TEMPLATE = "base"

Expand All @@ -22,6 +26,40 @@ def read_qss_stylesheet_file(template_name: str) -> str:
)


def _validate_include_directives(template: str) -> None:
for match in _INCLUDE_LIKE_PATTERN.finditer(template):
line = match.group(0)
if not _INCLUDE_PATTERN.fullmatch(line):
raise QssProcessingError(
f"Malformed @include directive: {line.strip()}. "
'Expected the form: @include "name.qss.in"'
)
Comment on lines +30 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to separate the two pattern checks? What happens if we just use _INCLUDE_PATTERN from the start?



def resolve_includes(template: str, *, _seen: frozenset[str] | None = None) -> str:
"""Replace ``@include "file.qss.in"`` lines with the file's content.

A directive must occupy a whole line, may be indented, and must reference a
``.qss.in`` file. Includes may nest; circular references are detected.
"""
if _seen is None:
_seen = frozenset()

_validate_include_directives(template)

def _replacer(match: re.Match[str]) -> str:
filename = match.group(1)
stem = filename.removesuffix(".qss.in")
if stem in _seen:
raise QssProcessingError(
f"Circular @include detected: {stem} is already being processed"
)
content = read_qss_stylesheet_file(stem)
return resolve_includes(content, _seen=_seen | {stem})

return _INCLUDE_PATTERN.sub(_replacer, template)


def substitute_tokens(template: str, tokens: dict[str, str]) -> str:
"""Replace all {{token-name}} placeholders with values from the token dict."""
for name, value in tokens.items():
Expand All @@ -37,5 +75,6 @@ def substitute_tokens(template: str, tokens: dict[str, str]) -> str:
def process_qss(theme: ColorTheme) -> str:
"""Load tokens for the given theme and produce a fully-resolved QSS string."""
raw = read_qss_stylesheet_file(_BASE_TEMPLATE)
resolved = resolve_includes(raw)
tokens = load_tokens(theme)
return substitute_tokens(raw, tokens)
return substitute_tokens(resolved, tokens)
183 changes: 158 additions & 25 deletions tests/ert/unit_tests/gui/theme_manager/test_qss_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from ert.gui.theme_manager.qss_processing import (
QssProcessingError,
process_qss,
read_qss_stylesheet_file,
resolve_includes,
substitute_tokens,
)
from ert.gui.theme_manager.theme_utils import ColorTheme
Expand Down Expand Up @@ -77,30 +77,6 @@ def test_that_substitute_tokens_raises_for_placeholder_with_underscores() -> Non
substitute_tokens("color: {{bg_canvas}};", {"bg-canvas": "#fff"})


def test_that_read_qss_stylesheet_file_returns_template_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: f"content-of-{filename}",
)
result = read_qss_stylesheet_file("main")
assert result == "content-of-qss_stylesheet/main.qss.in"


def test_that_read_qss_stylesheet_file_raises_file_not_found_for_missing_template(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _raise(*, filename: str, resource_kind: str) -> str:
raise FileNotFoundError(f"not found: {resource_kind}")

monkeypatch.setattr(qss_mod, "read_theming_resource", _raise)

with pytest.raises(FileNotFoundError, match=r"not found.*QSS template"):
read_qss_stylesheet_file("missing")


def test_that_process_qss_returns_fully_resolved_stylesheet(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down Expand Up @@ -153,3 +129,160 @@ def test_that_qss_processing_error_is_an_exception() -> None:
assert issubclass(QssProcessingError, Exception)
err = QssProcessingError("boom")
assert str(err) == "boom"


def test_that_resolve_includes_replaces_include_directive_with_file_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: "/* sidebar styles */",
)
template = 'before\n@include "sidebar.qss.in"\nafter'
result = resolve_includes(template)
assert result == "before\n/* sidebar styles */\nafter"
Comment thread
kaamyashinde marked this conversation as resolved.


def test_that_resolve_includes_handles_multiple_includes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
contents = {
"qss_stylesheet/sidebar.qss.in": "sidebar",
"qss_stylesheet/nav.qss.in": "nav",
}
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: contents[filename],
)
template = '@include "sidebar.qss.in"\n@include "nav.qss.in"'
result = resolve_includes(template)
assert result == "sidebar\nnav"


def test_that_resolve_includes_supports_nested_includes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
contents = {
"qss_stylesheet/outer.qss.in": '@include "inner.qss.in"',
"qss_stylesheet/inner.qss.in": "inner-content",
}
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: contents[filename],
)
template = '@include "outer.qss.in"'
result = resolve_includes(template)
assert result == "inner-content"


def test_that_resolve_includes_detects_circular_references(
monkeypatch: pytest.MonkeyPatch,
) -> None:
contents = {
"qss_stylesheet/a.qss.in": '@include "b.qss.in"',
"qss_stylesheet/b.qss.in": '@include "a.qss.in"',
}
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: contents[filename],
)
template = '@include "a.qss.in"'
with pytest.raises(QssProcessingError, match="Circular @include"):
resolve_includes(template)


def test_that_resolve_includes_returns_template_unchanged_when_no_includes() -> None:
template = "QWidget { color: black; }"
assert resolve_includes(template) == template


def test_that_resolve_includes_raises_file_not_found_for_missing_include(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _raise(*, filename: str, resource_kind: str) -> str:
raise FileNotFoundError(f"not found: {resource_kind}")

monkeypatch.setattr(qss_mod, "read_theming_resource", _raise)

template = '@include "missing.qss.in"'
with pytest.raises(FileNotFoundError):
resolve_includes(template)


def test_that_resolve_includes_resolves_indented_include_directive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: "nav",
)
template = 'before\n @include "nav.qss.in"\nafter'
assert resolve_includes(template) == "before\nnav\nafter"


def test_that_resolve_includes_resolves_include_with_trailing_whitespace(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: "nav",
)
template = '@include "nav.qss.in" \nafter'
assert resolve_includes(template) == "nav\nafter"


@pytest.mark.parametrize(
"template",
[
'@include "nav.css"',
'@include "nav"',
],
ids=["wrong-suffix", "no-suffix"],
)
def test_that_resolve_includes_raises_for_include_of_non_qss_in_file(
template: str,
) -> None:
with pytest.raises(QssProcessingError, match="Malformed @include directive"):
resolve_includes(template)


def test_that_resolve_includes_raises_for_include_without_quoted_filename() -> None:
with pytest.raises(QssProcessingError, match="Malformed @include directive"):
resolve_includes("@include nav.qss.in")


def test_that_resolve_includes_raises_for_trailing_content_after_include() -> None:
with pytest.raises(QssProcessingError, match="Malformed @include directive"):
resolve_includes('@include "nav.qss.in" extra')


def test_that_resolve_includes_error_message_names_the_offending_directive() -> None:
with pytest.raises(QssProcessingError, match=r'@include "nav\.css"'):
resolve_includes(' @include "nav.css" ')


def test_that_resolve_includes_ignores_include_word_inside_a_rule_body() -> None:
template = 'QWidget { qproperty-name: "@include nav"; }'
assert resolve_includes(template) == template


def test_that_resolve_includes_raises_for_malformed_include_in_included_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
contents = {
"qss_stylesheet/outer.qss.in": '@include "inner.css"',
}
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: contents[filename],
)

with pytest.raises(QssProcessingError, match=r'@include "inner\.css"'):
resolve_includes('@include "outer.qss.in"')
26 changes: 26 additions & 0 deletions tests/ert/unit_tests/gui/theme_manager/test_theme_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import pytest

from ert.gui.theme_manager import design_token as design_token_mod
from ert.gui.theme_manager import qss_processing as qss_mod
from ert.gui.theme_manager import theme_utils
from ert.gui.theme_manager.design_token import read_design_token_file
from ert.gui.theme_manager.qss_processing import read_qss_stylesheet_file
from ert.gui.theme_manager.theme_utils import ColorTheme, read_theming_resource


Expand Down Expand Up @@ -62,3 +64,27 @@ def _raise(*, filename: str, resource_kind: str) -> str:

with pytest.raises(FileNotFoundError, match=r"not found.*design token"):
read_design_token_file(ColorTheme.DARK)


def test_that_read_qss_stylesheet_file_returns_template_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
qss_mod,
"read_theming_resource",
lambda *, filename, resource_kind: f"content-of-{filename}",
)
result = read_qss_stylesheet_file("main")
assert result == "content-of-qss_stylesheet/main.qss.in"


def test_that_read_qss_stylesheet_file_raises_file_not_found_for_missing_template(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _raise(*, filename: str, resource_kind: str) -> str:
raise FileNotFoundError(f"not found: {resource_kind}")

monkeypatch.setattr(qss_mod, "read_theming_resource", _raise)

with pytest.raises(FileNotFoundError, match=r"not found.*QSS template"):
read_qss_stylesheet_file("missing")
Loading