diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2611f0c..4b9b479 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -16,7 +16,7 @@ Describe what you'd like to see added or changed. - Is this a **new widget style** in `shadcn.jinja`? - A **new design token** in the XML theme? -- A **new public API** (`apply_theme`, `ShadcnThemeTokens`, etc.)? +- A **new public API** (`setTheme`, `setThemeMode`, `ShadcnThemeTokens`, etc.)? - Something else? ## Reference design diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4913ea..aebd73d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,13 +99,10 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest] - python-version: ["3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.12", "3.13"] binding: [pyside6, pyqt6] include: - - os: ubuntu-latest - python-version: "3.12" - binding: pyside2 - os: ubuntu-latest python-version: "3.12" binding: pyqt5 @@ -160,10 +157,11 @@ jobs: - name: Assert QtBindingError without binding run: | uv run python - <<'PY' - from qtshadcn import QtBindingError try: - from qtshadcn import apply_theme # noqa: F401 - except QtBindingError as exc: + import qtshadcn # noqa: F401 + except ImportError as exc: + if "No supported Qt binding found" not in str(exc): + raise print("OK: QtBindingError raised:", exc) else: raise SystemExit("Expected QtBindingError when no binding is installed") diff --git a/AGENTS.md b/AGENTS.md index e1ea2b0..abafb1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ QtShadcn loads a local **XML theme file** with `` and `` palettes, ```text qtshadcn/ ├── __init__.py # Public package exports -├── models.py # Pydantic models: ShadcnTheme, ShadcnThemeTokens (ThemeConfig is internal) +├── models.py # Pydantic models: ShadcnTheme and ShadcnThemeTokens ├── exceptions.py # QtShadcnError, ThemeParseError, ThemeRenderError, QtBindingError ├── common/ # Internal runtime modules │ ├── binding.py # Binding-neutral Qt shim (PySide6 / PyQt6) @@ -31,7 +31,7 @@ qtshadcn/ │ ├── helpers.py # Application, file, mtime, font, and token-override helpers │ ├── icon.py # Runtime themed SVG icon cache helpers │ ├── renderer.py # QSS stylesheet renderer -│ ├── theme.py # Public API: apply_theme(), get_theme() +│ ├── stylesheet.py # Public API: setThemeMode(), setTheme(), setStyleSheet(), etc. │ ├── theme_mode.py # Theme mode normalization and dark/light resolution │ └── theme_parser.py # Internal XML parsing implementation ├── styles/ @@ -108,7 +108,9 @@ pre-commit run renovate-config-validator # validate Renovate config - **No hardcoded colors or pixel values in the Jinja template** — use tokens and helpers exclusively - **Token helpers** (`colors.alpha`, `colors.mix`, `radius.class_px`, `scale.spacing_px`) are the only way to transform token values - **Theme tokens are defined in XML** (`themes/default.xml`) — do not add Python-level color constants -- **Public API surface**: `apply_theme()`, `get_theme()`, `ShadcnThemeTokens` — keep it minimal +- **Public API surface**: `qsettings`, `ThemeMode`, `setThemeMode()`, `toggleThemeMode()`, + `themeMode()`, `isDarkTheme()`, `setTheme()`, `getTheme()`, `setStyleSheet()`, + `getStyleSheet()`, `SystemThemeListener`, `ShadcnThemeTokens` — keep it minimal --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba08b99..96ab0ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,7 +68,9 @@ To keep the project maintainable, follow these rules when making changes: - **No hardcoded colors or pixel values in the Jinja template** — use tokens and helpers exclusively. - **Token helpers** (`colors.alpha`, `colors.mix`, `radius.class_px`, `scale.spacing_px`) are the only way to transform token values. - **Theme tokens are defined in XML** (`themes/default.xml`) — do not add Python-level color constants. -- **Public API surface**: `apply_theme()`, `get_theme()`, `ShadcnThemeTokens` — keep it minimal. +- **Public API surface**: `qsettings`, `ThemeMode`, `setThemeMode()`, `toggleThemeMode()`, + `themeMode()`, `isDarkTheme()`, `setTheme()`, `getTheme()`, `setStyleSheet()`, + `getStyleSheet()`, `SystemThemeListener`, `ShadcnThemeTokens` — keep it minimal. --- diff --git a/README.md b/README.md index a1959bb..20ae7da 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,13 @@ export QT_API=pyqt5 ```python import sys from qtpy import QtWidgets -from qtshadcn import apply_theme +from qtshadcn import setTheme, setThemeMode, getTheme app = QtWidgets.QApplication(sys.argv) -tokens = apply_theme( - app, - theme_file="path/to/my_theme.xml", - theme_mode="auto", # "auto" | "light" | "dark" -) +setThemeMode("auto", save=False) # "auto" | "light" | "dark" +setTheme("path/to/my_theme.xml", save=False) +tokens = getTheme() print(tokens.primary) # resolved hex color label = QtWidgets.QLabel("Hello, QtShadcn!") @@ -168,24 +166,55 @@ Unknown tokens are silently ignored so you can extend the format freely. ## API Reference -### `apply_theme(app, theme_file, *, theme_mode, custom_tokens, additional_qss, default_theme) -> ShadcnThemeTokens` +QtShadcn exposes a small, composable public API directly from the package root: + +```python +from qtshadcn import ( + qsettings, + ThemeMode, + setThemeMode, + toggleThemeMode, + themeMode, + isDarkTheme, + setTheme, + getTheme, + setStyleSheet, + getStyleSheet, + SystemThemeListener, +) +``` + +### `setThemeMode(mode, *, save=True)` + +Set the active theme mode (`"auto"`, `"light"`, or `"dark"`) and re-render the stylesheet. + +### `toggleThemeMode(*, save=True)` + +Cycle the theme mode: auto → light → dark → auto. + +### `themeMode() -> ThemeMode` + +Return the current `ThemeMode`. + +### `isDarkTheme() -> bool` + +Return whether the resolved active palette is dark. + +### `setTheme(source, *, custom_tokens=None, save=True)` + +Load a QtShadcn `.xml` or `.json` theme, apply optional token overrides, and re-render the stylesheet. + +### `getTheme() -> ShadcnThemeTokens` -Parses the XML theme, renders the QSS stylesheet, and calls `app.setStyleSheet()`. +Return the resolved tokens for the active mode. -| Parameter | Type | Default | Description | -| --- | --- | --- | --- | -| `app` | `QApplication \| None` | `None` | The running Qt application instance | -| `theme_file` | `str \| None` | `None` | Path to the `.xml` theme file; `None` loads the default | -| `theme_mode` | `str` | `"auto"` | `"auto"`, `"light"`, or `"dark"` | -| `custom_tokens` | `dict[str, dict[str, str] \| str] \| None` | `None` | Token overrides (mode-specific when keys are `"light"`/`"dark"`) | -| `additional_qss` | `str \| None` | `None` | Inline Jinja snippet, `.qss` file path, or `.jinja` file path to append | -| `default_theme` | `str` | `"dark"` | Fallback mode when `theme_mode="auto"` and OS detection fails | +### `setStyleSheet(source, *, save=True)` -Returns the active `ShadcnThemeTokens` (light or dark, resolved). +Set an additional stylesheet (inline QSS/Jinja string or `.qss`/`.jinja` file path) layered on top of the base QSS. -### `get_theme() -> ShadcnTheme | None` +### `getStyleSheet() -> str` -Returns the full resolved theme (both palettes) from disk cache, or `None` if no theme has been applied yet. +Return the current additional stylesheet content. ### `ShadcnThemeTokens` diff --git a/docs/api-reference.md b/docs/api-reference.md index fc35599..c3c719c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -4,8 +4,17 @@ All public symbols are importable directly from `qtshadcn`: ```python from qtshadcn import ( - apply_theme, - get_theme, + qsettings, + ThemeMode, + setThemeMode, + toggleThemeMode, + themeMode, + isDarkTheme, + setTheme, + getTheme, + setStyleSheet, + getStyleSheet, + SystemThemeListener, ShadcnTheme, ShadcnThemeTokens, ThemeParseError, @@ -29,57 +38,116 @@ installed. ## Functions -### `apply_theme` +### `setThemeMode` ```python -def apply_theme( - app: QApplication | None = None, - theme_file: str | None = None, +def setThemeMode(mode: ThemeMode | str, *, save: bool = True) -> None +``` + +Set the active theme mode and re-render the stylesheet. + +**Parameters:** + +| Name | Type | Description | +| --- | --- | --- | +| `mode` | `ThemeMode \| str` | `ThemeMode.AUTO`, `ThemeMode.LIGHT`, `ThemeMode.DARK`, or `"auto"`, `"light"`, `"dark"`. | +| `save` | `bool` | When `True` (default), persist the mode to `config/theme_mode.json`. | + +--- + +### `toggleThemeMode` + +```python +def toggleThemeMode(*, save: bool = True) -> None +``` + +Cycle the theme mode: auto → light → dark → auto. + +--- + +### `themeMode` + +```python +def themeMode() -> ThemeMode +``` + +Return the active theme mode. + +--- + +### `isDarkTheme` + +```python +def isDarkTheme() -> bool +``` + +Return whether the resolved active palette is dark. + +--- + +### `setTheme` + +```python +def setTheme( + source: str | Path, *, - theme_mode: str = "auto", - custom_tokens: dict[str, dict[str, str] | str] | None = None, - additional_qss: str | None = None, - default_theme: str = "dark", -) -> ShadcnThemeTokens + custom_tokens: dict[str, Any] | None = None, + save: bool = True, +) -> None ``` -Applies a QtShadcn XML theme to the running `QApplication`. +Load a QtShadcn XML or JSON theme, apply optional token overrides, and re-render the stylesheet. **Pipeline:** -1. Resolves the theme source path from `theme_file` (or the packaged default theme) -2. Detects light/dark mode based on `theme_mode` -3. Checks disk cache — skips re-parsing if inputs and source file are unchanged -4. Parses the XML, applies `custom_tokens`, resolves all color tokens, renders QSS via Jinja2 -5. Appends `additional_qss` and calls `app.setStyleSheet(qss)` -6. Returns the active `ShadcnThemeTokens` +1. Resolves the theme source path +2. Parses the XML/JSON, applies `custom_tokens`, resolves all color tokens +3. Persists the palette when `save=True` +4. Renders QSS via Jinja2 and calls `app.setStyleSheet(qss)` **Parameters:** | Name | Type | Description | | --- | --- | --- | -| `app` | `QApplication \| None` | The running Qt application. If `None`, `QApplication.instance()` is used. | -| `theme_file` | `str \| None` | Path to the `.xml` theme file. `None` loads the built-in default theme. | -| `theme_mode` | `str` | `"auto"`, `"light"`, or `"dark"`. Default: `"auto"`. | -| `custom_tokens` | `dict[str, dict[str, str] \| str] \| None` | Token overrides. Mode-specific when top-level keys are `"light"` and/or `"dark"`. | -| `additional_qss` | `str \| None` | Inline Jinja snippet, `.qss` file path, or `.jinja` file path to append. | -| `default_theme` | `str` | Fallback mode when `theme_mode="auto"` and OS detection fails. `"light"` or `"dark"`. | +| `source` | `str \| Path` | Path to the `.xml` or `.json` theme file. | +| `custom_tokens` | `dict[str, Any] \| None` | Token overrides. Mode-specific when top-level keys are `"light"` and/or `"dark"`. | +| `save` | `bool` | When `True` (default), persist the palette to `config/`. | + +**Raises:** `ThemeParseError` — if the file is missing, malformed, or has missing tokens. -**Returns:** `ShadcnThemeTokens` — the resolved palette for the active mode. +--- -**Raises:** `ThemeParseError` — if the file is missing, malformed, or has missing tokens. `QtShadcnError` — if no `QApplication` instance is available or an argument is invalid. +### `getTheme` + +```python +def getTheme() -> ShadcnThemeTokens +``` + +Return the active palette tokens for the current mode. --- -### `get_theme` +### `setStyleSheet` ```python -def get_theme() -> ShadcnTheme | None +def setStyleSheet(source: str | Path, *, save: bool = True) -> None ``` -Returns the full resolved theme (both light and dark palettes) from disk cache. +Set the additional stylesheet layered on top of the base QSS. + +`source` can be an inline QSS/Jinja string or a path to a `.qss`/`.jinja` file. +When `save=True`, the content is persisted to `config/style.qss` or +`config/style.jinja`. + +--- + +### `getStyleSheet` + +```python +def getStyleSheet() -> str +``` -Returns `None` if `apply_theme` has never been called or the cache is absent. +Return the current additional stylesheet content. --- @@ -119,7 +187,7 @@ See the [Roadmap](roadmap.md) for planned widget coverage. ## Theme Mode -`theme_mode` accepts the following string values: +`ThemeMode` accepts the following values: | Value | Behavior | | --- | --- | @@ -127,7 +195,8 @@ See the [Roadmap](roadmap.md) for planned widget coverage. | `"light"` | Always uses the `` palette | | `"dark"` | Always uses the `` palette | -When `theme_mode="auto"` and the OS preference cannot be detected, `default_theme` is used. +When the mode is `"auto"` and the OS preference cannot be detected, the internal +default of `"dark"` is used. --- @@ -135,7 +204,7 @@ When `theme_mode="auto"` and the OS preference cannot be detected, `default_them ### `ShadcnThemeTokens` -Immutable Pydantic model holding the resolved tokens for a single palette (light **or** dark). Returned by `apply_theme`. +Immutable Pydantic model holding the resolved tokens for a single palette (light **or** dark). Returned by `getTheme`. All fields are `str` and required. Unknown XML tokens are ignored (`extra="ignore"`). @@ -170,7 +239,7 @@ The example values below come from the packaged `default.xml` light palette. The ### `ShadcnTheme` -Immutable Pydantic model holding both palettes. Returned by `get_theme`. +Immutable Pydantic model holding both palettes. | Field | Type | | --- | --- | @@ -187,7 +256,7 @@ Immutable Pydantic model holding both palettes. Returned by `get_theme`. class ThemeParseError(ValueError): ... ``` -Raised by `apply_theme` and `parse_theme_source` when the theme file cannot be loaded or is invalid. +Raised by `setTheme` and `parse_theme_source` when the theme file cannot be loaded or is invalid. Common causes: @@ -197,10 +266,10 @@ Common causes: - Token value is empty ```python -from qtshadcn import apply_theme, ThemeParseError +from qtshadcn import setTheme, ThemeParseError try: - apply_theme(app, theme_file="my_theme.xml", theme_mode="auto") + setTheme("my_theme.xml") except ThemeParseError as e: print(f"Theme error: {e}") ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index ab1e6e1..f31fe13 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -104,20 +104,18 @@ Save this as `my_theme.xml` next to your script: ```python import sys from qtpy import QtWidgets -from qtshadcn import ThemeParseError, apply_theme +from qtshadcn import ThemeParseError, setTheme, setThemeMode, getTheme app = QtWidgets.QApplication(sys.argv) try: - tokens = apply_theme( - app, - theme_file="my_theme.xml", - theme_mode="auto", # follows the OS — or use "light" / "dark" - ) + setThemeMode("auto", save=False) # follows the OS — or use "light" / "dark" + setTheme("my_theme.xml", save=False) except ThemeParseError as e: print(f"Could not load theme: {e}") sys.exit(1) +tokens = getTheme() print(f"Active primary color: {tokens.primary}") btn = QtWidgets.QPushButton("Hello, QtShadcn!") @@ -129,12 +127,13 @@ sys.exit(app.exec()) ## Using the Default Theme -QtShadcn ships with a default theme. Pass no `theme_file` and it loads automatically: +QtShadcn ships with a default theme. Load it by calling `setTheme` with no argument, or simply query the active tokens after setting the mode: ```python -from qtshadcn import apply_theme +from qtshadcn import getTheme, setThemeMode -apply_theme(app, theme_mode="dark") +setThemeMode("dark", save=False) +tokens = getTheme() ``` --- @@ -147,7 +146,7 @@ QtShadcn includes **Open Sans** and **Roboto** under `qtshadcn/fonts/`. To activ Open Sans, system-ui, sans-serif ``` -The fonts are registered automatically by `apply_theme` before the stylesheet is applied. +The fonts are registered automatically when the stylesheet is rendered. --- @@ -159,12 +158,13 @@ The fonts are registered automatically by `apply_theme` before the stylesheet is | `"light"` | Always use the `` palette | | `"dark"` | Always use the `` palette | -The `theme_mode` argument accepts the string values `"auto"`, `"light"`, and `"dark"`: +`setThemeMode` accepts the string values `"auto"`, `"light"`, and `"dark"`: ```python -from qtshadcn import apply_theme +from qtshadcn import setTheme, setThemeMode -apply_theme(app, theme_file="my_theme.xml", theme_mode="dark") +setThemeMode("dark", save=False) +setTheme("my_theme.xml", save=False) ``` --- diff --git a/docs/index.md b/docs/index.md index 1dadb1c..e1375f6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,15 +23,12 @@ QtShadcn applies a design-token–based QSS stylesheet to your `QApplication` in ```python import sys from qtpy import QtWidgets -from qtshadcn import apply_theme +from qtshadcn import setTheme, setThemeMode app = QtWidgets.QApplication(sys.argv) -apply_theme( - app, - theme_file="my_theme.xml", - theme_mode="auto", -) +setThemeMode("auto", save=False) +setTheme("my_theme.xml", save=False) btn = QtWidgets.QPushButton("Hello, QtShadcn!") btn.show() @@ -69,5 +66,5 @@ See [Getting Started](getting-started.md) for the full setup guide. ## What's Next - See the [Theme Format](theme-format.md) for every supported token and color syntax. -- See [API Reference](api-reference.md) for `apply_theme`, `ShadcnThemeTokens`, and the supported widget list. +- See [API Reference](api-reference.md) for `setTheme`, `setThemeMode`, `ShadcnThemeTokens`, and the supported widget list. - See [Roadmap](roadmap.md) for planned widget coverage and release milestones. diff --git a/docs/theme-format.md b/docs/theme-format.md index f2807cc..2ab70d6 100644 --- a/docs/theme-format.md +++ b/docs/theme-format.md @@ -19,7 +19,7 @@ Both sections are required. Unknown tokens are silently ignored, so you can add ## Required Tokens -Every palette section must contain **all 22 tokens** listed below. If any are missing, `apply_theme` raises a `ThemeParseError`. +Every palette section must contain **all 22 tokens** listed below. If any are missing, `setTheme` raises a `ThemeParseError`. | Token | Description | Example | | --- | --- | --- | @@ -99,7 +99,7 @@ Converted to `px` using `1rem = 16px`. `0.5rem` becomes `8px`. ## Fonts -QtShadcn ships with **Open Sans** and **Roboto** under `qtshadcn/fonts/`. They are registered automatically by `apply_theme`. +QtShadcn ships with **Open Sans** and **Roboto** under `qtshadcn/fonts/`. They are registered automatically when the stylesheet is rendered. To use them, reference the family name in your theme: diff --git a/examples/basic/main.py b/examples/basic/main.py new file mode 100644 index 0000000..21d6e5e --- /dev/null +++ b/examples/basic/main.py @@ -0,0 +1,115 @@ +"""Basic QtShadcn example — UI loaded from main_window.ui. + +Demonstrates the main widget types styled by QtShadcn, with a live +light/dark theme toggle. The interface is defined in main_window.ui and +loaded at runtime; no custom theme file is required — the bundled default +theme is resolved at start-up. +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +from qtpy import API_NAME, QtWidgets +from qtshadcn import isDarkTheme, setThemeMode, toggleThemeMode +from rich.logging import RichHandler + +logging.basicConfig( + level=logging.INFO, + format="%(message)s", + handlers=[RichHandler()], +) + +logger = logging.getLogger(__name__) + + +UI_FILE = str(Path(__file__).resolve().parent / "main_window.ui") + + +# --------------------------------------------------------------------------- +# UI loader +# --------------------------------------------------------------------------- + + +def _load_ui(ui_file: str | Path, base_instance: QtWidgets.QWidget) -> QtWidgets.QWidget: + """Load a Qt Designer ``.ui`` file for the active binding. + + PySide bindings provide ``QUiLoader``; PyQt bindings provide ``uic.loadUi``. + qtpy does not expose a unified ``loadUi`` across all four bindings, so we + dispatch locally. + """ + path = str(ui_file) + if API_NAME in ("PySide6", "PySide2"): + from qtpy.QtUiTools import QUiLoader + + return QUiLoader().load(path, base_instance) + if API_NAME == "PyQt6": + from PyQt6.uic import loadUi as _loadUi + + return _loadUi(path, base_instance) + if API_NAME == "PyQt5": + from PyQt5.uic import loadUi as _loadUi + + return _loadUi(path, base_instance) + raise RuntimeError(f"Unsupported Qt binding: {API_NAME}") + + +# --------------------------------------------------------------------------- +# Main window +# --------------------------------------------------------------------------- + + +class MainWindow(QtWidgets.QMainWindow): + """Main application window for the QtShadcn basic example.""" + + def __init__(self) -> None: + """Initialise the window, load the .ui file, and wire up signals.""" + super().__init__() + self.ui = _load_ui(UI_FILE, self) + self.setWindowTitle("QtShadcn \u2014 Basic Example") + self.resize(880, 620) + self._connect_signals() + + # ------------------------------------------------------------------ + # Signal wiring + # ------------------------------------------------------------------ + + def _connect_signals(self) -> None: + """Connect widget signals to their handler slots.""" + self.ui.themeToggle.clicked.connect(lambda: toggleThemeMode(save=True)) + self.ui.slider.valueChanged.connect(lambda v: self.ui.sliderValueLabel.setText(str(v))) + self.ui.textEditor.setPlainText( + "QtShadcn applies a consistent design language across all Qt widgets.\n" + "Edit this text to see the styled QTextEdit in action." + ) + + # ------------------------------------------------------------------ + # Theme toggle + # ------------------------------------------------------------------ + + def _toggle_label(self) -> str: + """Return the appropriate toggle button label for the current theme mode.""" + return "\u263e Dark" if isDarkTheme() else "\u2600 Light" + + def _on_toggle_theme(self) -> None: + """Toggle between light and dark theme modes.""" + toggleThemeMode(save=True) + self.ui.themeToggle.setText(self._toggle_label()) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +if __name__ == "__main__": + app = QtWidgets.QApplication(sys.argv) + + setThemeMode("dark", save=True) + + window = MainWindow() + window.show() + + sys.exit(app.exec()) diff --git a/examples/basic/main_window.ui b/examples/basic/main_window.ui new file mode 100644 index 0000000..0956342 --- /dev/null +++ b/examples/basic/main_window.ui @@ -0,0 +1,236 @@ + + + MainWindow + + 00880620 + QtShadcn — Basic Example + + + 0 + 0 + 0 + 0 + 0 + + + + 24 + 12 + 24 + 12 + QtShadcn Basic Example + Qt::Horizontal4020 + 120☾ Dark + + + + + + true + QFrame::NoFrame + + 00832540 + + 20 + 24 + 20 + 24 + 28 + + + + 8 + 0000 + Buttonstrue + QFrame::HLineQFrame::Sunken + + + 8 + Primary + Secondary + Outline + Destructive + falseDisabled + Qt::Horizontal4020 + + + + + + + + + 8 + 0000 + Line Inputstrue + QFrame::HLineQFrame::Sunken + + + 8 + Username: + Enter username… + Password: + QLineEdit::PasswordEnter password… + Read-only: + Read-only valuetrue + + + + + + + + + 8 + 0000 + Checkboxestrue + QFrame::HLineQFrame::Sunken + + + 6 + Accept terms and conditionstrue + Subscribe to newsletterfalse + Enable notificationstrue + falseDisabled option + + + + + + + + + 8 + 0000 + Radio Buttonstrue + QFrame::HLineQFrame::Sunken + + + Select an option + + 6 + Option Atrue + Option B + falseOption C (disabled) + + + + + + + + + + 8 + 0000 + Slidertrue + QFrame::HLineQFrame::Sunken + + + 12 + 010042Qt::Horizontal + 3042 + + + + + + + + + 8 + 0000 + Progress Bartrue + QFrame::HLineQFrame::Sunken + + + 8 + 010025 + 010060 + 010090 + + + + + + + + + 8 + 0000 + ComboBoxtrue + QFrame::HLineQFrame::Sunken + + + 220 + System Default + Light Theme + Dark Theme + High Contrast + + + + + + + + + 8 + 0000 + Multi-line Texttrue + QFrame::HLineQFrame::Sunken + 100Type something here… + + + + + + + 8 + 0000 + Tab Widgettrue + QFrame::HLineQFrame::Sunken + + + 140 + 0 + + Overview + + This is the Overview tab. +Summary information goes here.true + Qt::Vertical2040 + + + + Details + + This is the Details tab. +More granular content lives here.true + Qt::Vertical2040 + + + + Settings + + This is the Settings tab. +Configuration options go here.true + Qt::Vertical2040 + + + + + + + + Qt::Vertical2040 + + + + + + + + + + diff --git a/examples/gallery/main.py b/examples/gallery/main.py index 6d89908..717734b 100644 --- a/examples/gallery/main.py +++ b/examples/gallery/main.py @@ -16,7 +16,7 @@ from xml.etree import ElementTree as ET from qtpy import API_NAME, QtCore, QtGui, QtWidgets -from qtshadcn import apply_theme, get_theme +from qtshadcn import qsettings, setStyleSheet, setTheme, setThemeMode from rich.logging import RichHandler logging.basicConfig( @@ -318,12 +318,9 @@ def _apply_editor_theme(self) -> None: tmp_file.write(_tokens_to_xml_bytes(self._tokens)) tmp_path = tmp_file.name try: - apply_theme( - self.app, - theme_file=tmp_path, - theme_mode=self._active_mode, - additional_qss=CUSTOM_PATH, - ) + setThemeMode(self._active_mode, save=False) + setTheme(tmp_path, save=False) + setStyleSheet(CUSTOM_PATH, save=False) finally: Path(tmp_path).unlink(missing_ok=True) # Re-apply color squares AFTER app.setStyleSheet so they survive re-polish @@ -402,12 +399,9 @@ def _on_theme_changed(self, index: int) -> None: """Apply the selected theme mode (0=Auto, 1=Light, 2=Dark).""" _mode_keys = ["auto", "light", "dark"] self._active_mode = _mode_keys[index] if _mode_keys[index] != "auto" else "dark" - apply_theme( - self.app, - theme_file=THEME_FILE, - theme_mode=_mode_keys[index], - additional_qss=CUSTOM_PATH, - ) + setThemeMode(_mode_keys[index], save=False) + setTheme(THEME_FILE, save=False) + setStyleSheet(CUSTOM_PATH, save=False) # Re-apply color squares after app stylesheet update self._refresh_editor_widgets() @@ -441,7 +435,8 @@ def enterEvent(self, event): super().enterEvent(event) def closeEvent(self, event): - """Clean up external views before closing the application.""" + """Persist current theme preferences before closing the application.""" + qsettings.save() logging.shutdown() super().closeEvent(event) @@ -463,13 +458,9 @@ def closeEvent(self, event): # Apply initial theme logger.info("Applying initial theme (Dark)") - get_theme() - apply_theme( - window.app, - theme_file=THEME_FILE, - theme_mode="dark", - additional_qss=CUSTOM_PATH, - ) + setThemeMode("dark", save=False) + setTheme(THEME_FILE, save=False) + setStyleSheet(CUSTOM_PATH, save=False) window.show() logger.info("Gallery .ui window displayed") diff --git a/examples/generator/main.py b/examples/generator/main.py new file mode 100644 index 0000000..1b366ef --- /dev/null +++ b/examples/generator/main.py @@ -0,0 +1,230 @@ +"""QtShadcn CSS → Theme Generator GUI. + +Loads main_window.ui, parses a pasted CSS snippet, and renders +the QtShadcn theme.xml content directly in the output textarea. +""" + +from __future__ import annotations + +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +from qtpy import API_NAME, QtCore, QtGui, QtWidgets +from qtshadcn import setTheme, setThemeMode + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +_HERE = Path(__file__).resolve().parent +UI_FILE = str(_HERE / "main_window.ui") +THEME_FILE = str(_HERE / "theme.xml") + +# --------------------------------------------------------------------------- +# CSS → XML token mapping (CSS var name → XML element tag) +# --------------------------------------------------------------------------- + +TOKEN_MAP: dict[str, str] = { + "background": "background", + "foreground": "foreground", + "card": "card", + "card-foreground": "card_foreground", + "popover": "popover", + "popover-foreground": "popover_foreground", + "primary": "primary", + "primary-foreground": "primary_foreground", + "secondary": "secondary", + "secondary-foreground": "secondary_foreground", + "muted": "muted", + "muted-foreground": "muted_foreground", + "accent": "accent", + "accent-foreground": "accent_foreground", + "destructive": "destructive", + "destructive-foreground": "destructive_foreground", + "border": "border", + "input": "input", + "ring": "ring", + "spacing": "spacing", + "radius": "radius", + "font-sans": "font_family", +} + +# --------------------------------------------------------------------------- +# Pure parsing helpers (no I/O) +# --------------------------------------------------------------------------- + + +def parse_css_block(css_content: str, selector: str) -> dict[str, str]: + """Extract CSS custom properties from a single selector block.""" + pattern = rf"{re.escape(selector)}\s*\{{([^}}]+)\}}" + match = re.search(pattern, css_content) + if not match: + return {} + block = match.group(1) + var_pattern = r"--([a-zA-Z0-9-]+)\s*:\s*([^;]+);" + return {name.strip(): value.strip() for name, value in re.findall(var_pattern, block)} + + +def tokens_to_xml_str(light: dict[str, str], dark: dict[str, str]) -> str: + """Render a QtShadcn-compatible theme XML string from two token dicts.""" + root = ET.Element("theme") + + for mode_tag, tokens in (("light", light), ("dark", dark)): + mode_el = ET.SubElement(root, mode_tag) + for css_var, xml_tag in TOKEN_MAP.items(): + if css_var in tokens: + child = ET.SubElement(mode_el, xml_tag) + child.text = tokens[css_var] + + ET.indent(root, space=" ") + return ET.tostring(root, encoding="unicode", xml_declaration=False) + + +def generate_xml_from_css(css: str) -> tuple[bool, str]: + """Parse CSS and return ``(success, xml_string_or_error_message)``.""" + light = parse_css_block(css, ":root") + dark = parse_css_block(css, ".dark") + + if not light and not dark: + return False, "No CSS custom properties found in :root or .dark blocks." + + return True, tokens_to_xml_str(light, dark) + + +# --------------------------------------------------------------------------- +# UI loader helper (PySide6 / PyQt6 compatible) +# --------------------------------------------------------------------------- + + +def _load_ui(ui_file: str | Path, base_instance: QtWidgets.QWidget) -> QtWidgets.QWidget: + """Load a Qt Designer .ui file for the active binding.""" + path = str(ui_file) + if API_NAME in ("PySide6", "PySide2"): + from qtpy.QtUiTools import QUiLoader + + return QUiLoader().load(path, base_instance) + if API_NAME == "PyQt6": + from PyQt6.uic import loadUi as _loadUi + + return _loadUi(path, base_instance) + if API_NAME == "PyQt5": + from PyQt5.uic import loadUi as _loadUi + + return _loadUi(path, base_instance) + raise RuntimeError(f"Unsupported Qt binding: {API_NAME}") + + +# --------------------------------------------------------------------------- +# Main window +# --------------------------------------------------------------------------- + + +class GeneratorWindow(QtWidgets.QMainWindow): + """Main window for the CSS → Theme Generator example.""" + + def __init__(self, app: QtWidgets.QApplication) -> None: + """Initialize the generator window and load the .ui file.""" + super().__init__() + self.app = app + self._old_pos = self.pos() + + self.setWindowTitle("QtShadcn — CSS → Theme Generator") + self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground) + + self.ui = _load_ui(UI_FILE, self) + self._connect_signals() + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + def _connect_signals(self) -> None: + """Wire all UI signals to their handlers.""" + self.ui.btnClose.clicked.connect(self.close) + self.ui.btnMinimize.clicked.connect(self.showMinimized) + self.ui.btnGenerate.clicked.connect(self._on_generate) + self.ui.btnClearCss.clicked.connect(self._on_clear_css) + self.ui.btnCopyXml.clicked.connect(self._on_copy_xml) + + # ------------------------------------------------------------------ + # Handlers + # ------------------------------------------------------------------ + + def _on_generate(self) -> None: + """Parse the CSS input and display the generated XML in the output pane.""" + css = self.ui.cssInput.toPlainText().strip() + if not css: + self._set_status("⚠ CSS input is empty.", error=True) + return + + ok, result = generate_xml_from_css(css) + if ok: + self.ui.xmlOutput.setPlainText(result) + self.ui.btnCopyXml.setEnabled(True) + self._set_status("✓ Theme XML generated successfully.") + else: + self.ui.xmlOutput.setPlainText("") + self.ui.btnCopyXml.setEnabled(False) + self._set_status(f"✗ {result}", error=True) + + def _on_clear_css(self) -> None: + """Clear the CSS input area.""" + self.ui.cssInput.clear() + self.ui.xmlOutput.clear() + self.ui.btnCopyXml.setEnabled(False) + self._set_status("") + + def _on_copy_xml(self) -> None: + """Copy the XML output to the system clipboard.""" + xml = self.ui.xmlOutput.toPlainText() + if xml: + QtWidgets.QApplication.clipboard().setText(xml) + self._set_status("✓ Copied to clipboard!") + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _set_status(self, message: str, *, error: bool = False) -> None: + """Update the status label text.""" + self.ui.statusLabel.setText(message) + + # ------------------------------------------------------------------ + # Frameless window drag + # ------------------------------------------------------------------ + + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + """Start window drag on left-click in the top bar.""" + if event.button() == QtCore.Qt.MouseButton.LeftButton: + pos = getattr(event, "globalPosition", None) + self._old_pos = pos().toPoint() if pos else event.globalPos() + + def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: + """Drag the frameless window.""" + if event.buttons() == QtCore.Qt.MouseButton.LeftButton: + pos = getattr(event, "globalPosition", None) + current = pos().toPoint() if pos else event.globalPos() + delta = QtCore.QPoint(current - self._old_pos) + self.move(self.x() + delta.x(), self.y() + delta.y()) + self._old_pos = current + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + legacy_flags = {"--pyside6", "--pyqt6"} + filtered_argv = [arg for arg in sys.argv if arg not in legacy_flags] + app = QtWidgets.QApplication(filtered_argv) + + setThemeMode("dark", save=False) + setTheme(THEME_FILE, save=False) + + window = GeneratorWindow(app) + window.show() + + sys.exit(app.exec() if hasattr(app, "exec") else app.exec_()) diff --git a/examples/generator/main_window.ui b/examples/generator/main_window.ui new file mode 100644 index 0000000..c88d277 --- /dev/null +++ b/examples/generator/main_window.ui @@ -0,0 +1,272 @@ + + + MainWindow + + + + 0 + 0 + 1100 + 720 + + + + QtShadcn — CSS → Theme Generator + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 63 + + + + + 16777215 + 63 + + + + QFrame::Shape::NoFrame + + + + 8 + + + 16 + + + 0 + + + 12 + + + 0 + + + + + font: 12pt ; +font-weight: 600; + + + CSS → Theme Generator + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Minimize + + + − + + + + + + + Close + + + ✕ + + + + + + + + + + 12 + + + 20 + + + 12 + + + 20 + + + 20 + + + + + 16 + + + + + CSS Input + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Theme XML Output + + + + + + + + + 16 + + + + + Paste your CSS here… + +:root { + --background: #ffffff; + --foreground: #0a0a0a; + --primary: #171717; + --radius: 0.5rem; + /* … */ +} + +.dark { + --background: #0a0a0a; + --foreground: #fafafa; + /* … */ +} + + + + + + + true + + + Generated theme.xml will appear here… + + + + + + + + + 8 + + + + + Parse the CSS and generate the QtShadcn theme XML + + + Generate Theme XML + + + + + + + Clear the CSS input area + + + Clear CSS + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + false + + + Copy the generated XML to the clipboard + + + Copy XML + + + + + + + + + + + + + diff --git a/examples/generator/style.css b/examples/generator/style.css new file mode 100644 index 0000000..e71449d --- /dev/null +++ b/examples/generator/style.css @@ -0,0 +1,173 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: #faf9f5; + --foreground: #3d3929; + --card: #faf9f5; + --card-foreground: #141413; + --popover: #ffffff; + --popover-foreground: #28261b; + --primary: #c96442; + --primary-foreground: #ffffff; + --secondary: #e9e6dc; + --secondary-foreground: #535146; + --muted: #ede9de; + --muted-foreground: #83827d; + --accent: #e9e6dc; + --accent-foreground: #28261b; + --destructive: #141413; + --destructive-foreground: #ffffff; + --border: #dad9d4; + --input: #b4b2a7; + --ring: #c96442; + --chart-1: #b05730; + --chart-2: #9c87f5; + --chart-3: #ded8c4; + --chart-4: #dbd3f0; + --chart-5: #b4552d; + --sidebar: #f5f4ee; + --sidebar-foreground: #3d3d3a; + --sidebar-primary: #c96442; + --sidebar-primary-foreground: #fbfbfb; + --sidebar-accent: #e9e6dc; + --sidebar-accent-foreground: #343434; + --sidebar-border: #ebebeb; + --sidebar-ring: #b5b5b5; + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --radius: 0.5rem; + --shadow-x: 0; + --shadow-y: 1px; + --shadow-blur: 3px; + --shadow-spread: 0px; + --shadow-opacity: 0.1; + --shadow-color: oklch(0 0 0); + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; + --spacing: 0.25rem; +} + +.dark { + --background: #262624; + --foreground: #c3c0b6; + --card: #262624; + --card-foreground: #faf9f5; + --popover: #30302e; + --popover-foreground: #e5e5e2; + --primary: #d97757; + --primary-foreground: #ffffff; + --secondary: #faf9f5; + --secondary-foreground: #30302e; + --muted: #1b1b19; + --muted-foreground: #b7b5a9; + --accent: #1a1915; + --accent-foreground: #f5f4ee; + --destructive: #ef4444; + --destructive-foreground: #ffffff; + --border: #3e3e38; + --input: #52514a; + --ring: #d97757; + --chart-1: #b05730; + --chart-2: #9c87f5; + --chart-3: #1a1915; + --chart-4: #2f2b48; + --chart-5: #b4552d; + --sidebar: #1f1e1d; + --sidebar-foreground: #c3c0b6; + --sidebar-primary: #343434; + --sidebar-primary-foreground: #fbfbfb; + --sidebar-accent: #0f0f0e; + --sidebar-accent-foreground: #c3c0b6; + --sidebar-border: #ebebeb; + --sidebar-ring: #b5b5b5; + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --radius: 0.5rem; + --shadow-x: 0; + --shadow-y: 1px; + --shadow-blur: 3px; + --shadow-spread: 0px; + --shadow-opacity: 0.1; + --shadow-color: oklch(0 0 0); + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/examples/generator/theme.xml b/examples/generator/theme.xml new file mode 100644 index 0000000..054324d --- /dev/null +++ b/examples/generator/theme.xml @@ -0,0 +1,50 @@ + + + #faf9f5 + #3d3929 + #faf9f5 + #141413 + #ffffff + #28261b + #c96442 + #ffffff + #e9e6dc + #535146 + #ede9de + #83827d + #e9e6dc + #28261b + #141413 + #ffffff + #dad9d4 + #b4b2a7 + #c96442 + 0.25rem + 0.5rem + ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' + + + #262624 + #c3c0b6 + #262624 + #faf9f5 + #30302e + #e5e5e2 + #d97757 + #ffffff + #faf9f5 + #30302e + #1b1b19 + #b7b5a9 + #1a1915 + #f5f4ee + #ef4444 + #ffffff + #3e3e38 + #52514a + #d97757 + 0.25rem + 0.5rem + ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' + + \ No newline at end of file diff --git a/qtshadcn/__init__.py b/qtshadcn/__init__.py index cf34d7c..f0ae946 100644 --- a/qtshadcn/__init__.py +++ b/qtshadcn/__init__.py @@ -1,51 +1,57 @@ -from typing import Any +"""QtShadcn — modern styling and theming framework for Qt/PyQt/PySide.""" from .exceptions import QtBindingError, QtShadcnError, ThemeParseError, ThemeRenderError from .models import ShadcnTheme, ShadcnThemeTokens __version__ = "0.2.0" +try: + from qtpy import QtBindingsNotFoundError, QtModuleNotInstalledError +except ImportError as exc: + raise QtBindingError( + "No supported Qt binding found. Install one of: PySide6, PyQt6, " + "PySide2, or PyQt5, then set QT_API to the binding name " + "(e.g., QT_API=pyside6) before importing qtshadcn." + ) from exc -def __getattr__(name: str) -> Any: - """Lazily resolve Qt-dependent package exports.""" - if name in {"apply_theme", "get_theme"}: - try: - from .common.theme import apply_theme, get_theme - except ImportError as exc: - try: - from qtpy import QtBindingsNotFoundError, QtModuleNotInstalledError - except ImportError: - # qtpy itself is unavailable; treat import failures that involve - # qtpy or a Qt binding as a binding error. - failed_module = getattr(exc, "name", "") or str(exc) or "" - root = failed_module.split(".", 1)[0] - if root in {"qtpy", "PySide6", "PyQt6", "PySide2", "PyQt5"}: - raise QtBindingError( - "No supported Qt binding found. Install one of: PySide6, PyQt6, " - "PySide2, or PyQt5, then set QT_API to the binding name " - "(e.g., QT_API=pyside6) before importing qtshadcn." - ) from exc - raise - if isinstance(exc, QtBindingsNotFoundError | QtModuleNotInstalledError): - raise QtBindingError( - "No supported Qt binding found. Install one of: PySide6, PyQt6, " - "PySide2, or PyQt5, then set QT_API to the binding name " - "(e.g., QT_API=pyside6) before importing qtshadcn." - ) from exc - raise - - return {"apply_theme": apply_theme, "get_theme": get_theme}[name] - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - +try: + from .common.config import ThemeMode, qsettings + from .common.stylesheet import ( + getStyleSheet, + getTheme, + isDarkTheme, + setStyleSheet, + setTheme, + setThemeMode, + themeMode, + toggleThemeMode, + ) + from .common.theme_watcher import SystemThemeListener +except (QtBindingsNotFoundError, QtModuleNotInstalledError) as exc: + raise QtBindingError( + "No supported Qt binding found. Install one of: PySide6, PyQt6, " + "PySide2, or PyQt5, then set QT_API to the binding name " + "(e.g., QT_API=pyside6) before importing qtshadcn." + ) from exc __all__ = [ + # Version + "__version__", # Models "ShadcnTheme", "ShadcnThemeTokens", - # Functions - "apply_theme", - "get_theme", - "__version__", + # API + "qsettings", + "setThemeMode", + "toggleThemeMode", + "themeMode", + "isDarkTheme", + "setTheme", + "getTheme", + "setStyleSheet", + "getStyleSheet", + "ThemeMode", + "SystemThemeListener", # Errors "QtShadcnError", "ThemeParseError", diff --git a/qtshadcn/common/cache.py b/qtshadcn/common/cache.py deleted file mode 100644 index 03896a4..0000000 --- a/qtshadcn/common/cache.py +++ /dev/null @@ -1,79 +0,0 @@ -"""QtShadcn theme cache persistence.""" - -import json -import logging -from pathlib import Path -from typing import Any, cast - -from qtpy import QtCore - -from ..models import ShadcnTheme, ThemeConfig - -logger = logging.getLogger(__name__) - - -def _cache_hit( - saved_config: ThemeConfig | None, - saved_theme: ShadcnTheme | None, - saved_mtime: float | None, - config: ThemeConfig, - current_mtime: float, -) -> bool: - """Return True when the saved cache matches the requested configuration.""" - return ( - saved_config is not None - and saved_theme is not None - and saved_mtime is not None - and saved_config == config - and saved_mtime == current_mtime - ) - - -def _save_theme(config: ThemeConfig, theme: ShadcnTheme, mtime: float) -> None: - """Persist configuration, resolved theme, and source mtime to AppData.""" - try: - app_data_path = QtCore.QStandardPaths.writableLocation( - cast(Any, QtCore.QStandardPaths.StandardLocation.AppDataLocation) - ) - dir_path = Path(app_data_path) - dir_path.mkdir(parents=True, exist_ok=True) - - file_path = dir_path / "theme.json" - - data = { - "config": config.model_dump(), - "theme": theme.model_dump(), - "mtime": mtime, - } - - with open(file_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - - except Exception as e: - logger.error("Could not save theme cache to AppData: %s", e) - - -def _load_theme_cache() -> tuple[ThemeConfig | None, ShadcnTheme | None, float | None]: - """Load the cached theme, configuration, and source mtime from disk.""" - try: - app_data_path = QtCore.QStandardPaths.writableLocation( - cast(Any, QtCore.QStandardPaths.StandardLocation.AppDataLocation) - ) - file_path = Path(app_data_path) / "theme.json" - - if not file_path.exists(): - return None, None, None - - with open(file_path, encoding="utf-8") as f: - data = json.load(f) - - saved_config = ThemeConfig.model_validate(data.get("config", {})) - saved_mtime = data.get("mtime") - saved_theme = None - if "theme" in data: - saved_theme = ShadcnTheme.model_validate(data["theme"]) - - return saved_config, saved_theme, saved_mtime - except Exception as e: - logger.error("Error reading theme cache: %s", e) - return None, None, None diff --git a/qtshadcn/common/config.py b/qtshadcn/common/config.py new file mode 100644 index 0000000..79d6cdb --- /dev/null +++ b/qtshadcn/common/config.py @@ -0,0 +1,294 @@ +"""QtShadcn persistent configuration system. + +Provides ``ThemeMode``, ``ConfigItem``, and the ``qsettings`` singleton that +backs the three-concept theme API in :mod:`~qtshadcn.common.stylesheet`. +""" + +from __future__ import annotations + +import json +import logging +import os +import warnings +from collections.abc import Callable +from enum import StrEnum +from pathlib import Path +from typing import Any, cast + +from qtpy import QtCore + +from ..models import ShadcnTheme +from .helpers import _looks_like_jinja + +logger = logging.getLogger(__name__) + + +class ThemeMode(StrEnum): + """Theme mode selection.""" + + AUTO = "auto" + LIGHT = "light" + DARK = "dark" + + +_QObject: Any = QtCore.QObject + + +class ConfigItem(_QObject): + """A single persisted configuration value with change notification. + + Args: + key: Identifier used for persistence and diagnostics. + default: Value returned before any load or set call. + validator: Optional callable that returns ``True`` for valid values. + parent: Optional Qt parent object. + + """ + + valueChanged = QtCore.Signal(object) + + def __init__( + self, + key: str, + default: Any, + validator: Callable[[Any], bool] | None = None, + parent: QtCore.QObject | None = None, + ) -> None: + """Create a config item with a key, default, and optional validator.""" + super().__init__(parent) + self._key = key + self._default = default + self._value = default + self._validator = validator + + @property + def value(self) -> Any: + """Return the current item value.""" + return self._value + + def set(self, value: Any, *, block_signal: bool = False) -> None: + """Set the item value and emit ``valueChanged`` unless blocked.""" + if self._validator is not None and not self._validator(value): + return + self._value = value + if not block_signal: + self.valueChanged.emit(value) + + def reset(self) -> None: + """Reset the value to the configured default.""" + self._value = self._default + + def serialize(self) -> Any: + """Return a JSON-serializable representation of the value.""" + return self._value + + def deserialize(self, raw: Any) -> bool: + """Load a raw value and return whether validation passed.""" + if self._validator is not None and not self._validator(raw): + return False + self._value = raw + return True + + +class QtShadcnSettings(_QObject): + """Persistent settings backbone for QtShadcn. + + Holds three scalar config items (``theme_mode``, ``theme`` source path, and + ``additional_style_sheet``) plus the parsed ``ShadcnTheme`` in memory. The + actual QSS rendering lives in + :mod:`~qtshadcn.common.stylesheet`. + """ + + themeChanged = QtCore.Signal() + additionalStyleSheetChanged = QtCore.Signal() + + def __init__(self, parent: QtCore.QObject | None = None) -> None: + """Create the settings backbone with default config items.""" + super().__init__(parent) + self.theme_mode = ConfigItem( + "theme_mode", + ThemeMode.AUTO.value, + self._validate_mode, + self, + ) + self.theme = ConfigItem( + "theme", + "", + self._validate_theme_source, + self, + ) + self.additional_style_sheet = ConfigItem( + "additional_style_sheet", + "", + None, + self, + ) + self._config_dir: Path | None = None + self._on_load: Callable[[QtShadcnSettings], None] | None = None + self._theme: ShadcnTheme | None = None + + @staticmethod + def _validate_mode(value: Any) -> bool: + return isinstance(value, str) and value in {m.value for m in ThemeMode} + + @staticmethod + def _validate_theme_source(value: Any) -> bool: + return isinstance(value, str) + + def config_dir(self) -> Path: + """Return the directory used for persisted config files.""" + if self._config_dir is not None: + return self._config_dir + app_data = QtCore.QStandardPaths.writableLocation( + cast(Any, QtCore.QStandardPaths.StandardLocation.AppDataLocation) + ) + return Path(app_data) / "config" + + def set_config_dir(self, config_dir: str | Path | None) -> None: + """Override the default config directory.""" + self._config_dir = Path(config_dir) if config_dir is not None else None + + def load( + self, + config_dir: str | Path | None = None, + *, + settings: QtShadcnSettings | None = None, + ) -> None: + """Restore persisted state from ``config_dir``. + + Args: + config_dir: Directory to read config files from. Uses + ``QStandardPaths.AppDataLocation/config/`` when omitted. + settings: Optional custom settings instance to populate instead of + the singleton. This lets advanced callers load into their own + settings object while still using the same file layout. + + """ + target = settings if settings is not None else self + if config_dir is not None: + target.set_config_dir(config_dir) + elif target._config_dir is None: + target.set_config_dir(None) + + cfg_dir = target.config_dir() + cfg_dir.mkdir(parents=True, exist_ok=True) + + self._load_theme_mode(target, cfg_dir) + self._load_theme(target, cfg_dir) + self._load_style_sheet(target, cfg_dir) + + callback = target._on_load if target._on_load is not None else self._on_load + if callback is not None: + callback(target) + + target.theme_mode.valueChanged.emit(target.theme_mode.value) + target.themeChanged.emit() + target.additionalStyleSheetChanged.emit() + + def _load_theme_mode(self, target: QtShadcnSettings, cfg_dir: Path) -> None: + mode_path = cfg_dir / "theme_mode.json" + mode = ThemeMode.AUTO.value + if mode_path.exists(): + try: + data = json.loads(mode_path.read_text(encoding="utf-8")) + mode = data.get("mode", ThemeMode.AUTO.value) + except (json.JSONDecodeError, OSError) as e: + warnings.warn( + f"Corrupt theme_mode.json, resetting to auto: {e}", + stacklevel=2, + ) + if not self._validate_mode(mode): + mode = ThemeMode.AUTO.value + target.theme_mode.set(mode, block_signal=True) + + def _load_theme(self, target: QtShadcnSettings, cfg_dir: Path) -> None: + theme_json = cfg_dir / "theme.json" + theme_xml = cfg_dir / "theme.xml" + source = "" + theme: ShadcnTheme | None = None + + if theme_json.exists(): + try: + data = json.loads(theme_json.read_text(encoding="utf-8")) + data.pop("version", None) + theme = ShadcnTheme.model_validate(data) + source = str(theme_json) + except (json.JSONDecodeError, OSError) as e: + warnings.warn(f"Corrupt theme.json, ignoring: {e}", stacklevel=2) + + if theme is None and theme_xml.exists(): + try: + from .theme_parser import parse_theme_source + + theme = parse_theme_source(theme_xml) + source = str(theme_xml) + except Exception as e: # pragma: no cover + warnings.warn(f"Corrupt theme.xml, ignoring: {e}", stacklevel=2) + + target._theme = theme + target.theme.set(source, block_signal=True) + + def _load_style_sheet(self, target: QtShadcnSettings, cfg_dir: Path) -> None: + style_jinja = cfg_dir / "style.jinja" + style_qss = cfg_dir / "style.qss" + source = "" + if style_jinja.exists(): + source = str(style_jinja) + elif style_qss.exists(): + source = str(style_qss) + target.additional_style_sheet.set(source, block_signal=True) + + def save(self, *, only: set[str] | None = None) -> None: + """Persist current state to ``config_dir``. + + Args: + only: Optional subset of + ``{"theme_mode", "theme", "additional_style_sheet"}`` to write. + When omitted, all known files are written. + + """ + cfg_dir = self.config_dir() + cfg_dir.mkdir(parents=True, exist_ok=True) + + keys = only if only is not None else {"theme_mode", "theme", "additional_style_sheet"} + if "theme_mode" in keys: + _atomic_write( + cfg_dir / "theme_mode.json", + json.dumps({"mode": self.theme_mode.value}, indent=2), + ) + if "theme" in keys and self._theme is not None: + data = {"version": 1, **self._theme.model_dump()} + _atomic_write(cfg_dir / "theme.json", json.dumps(data, indent=2)) + if "additional_style_sheet" in keys: + self._save_style_sheet(cfg_dir) + + def _save_style_sheet(self, cfg_dir: Path) -> None: + content = self.additional_style_sheet.value + if _looks_like_jinja(content): + _atomic_write(cfg_dir / "style.jinja", content) + (cfg_dir / "style.qss").unlink(missing_ok=True) + elif content: + _atomic_write(cfg_dir / "style.qss", content) + (cfg_dir / "style.jinja").unlink(missing_ok=True) + + def reset_for_test(self) -> None: + """Reset all values to defaults and clear custom config directory.""" + self.theme_mode.reset() + self.theme.reset() + self.additional_style_sheet.reset() + self._theme = None + self._config_dir = None + + +def _atomic_write(path: Path, content: str) -> None: + """Write ``content`` to ``path`` atomically via a temporary file.""" + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, path) + + +def _settings_singleton() -> QtShadcnSettings: + return QtShadcnSettings() + + +qsettings: QtShadcnSettings = _settings_singleton() diff --git a/qtshadcn/common/helpers.py b/qtshadcn/common/helpers.py index 9df855a..f01395a 100644 --- a/qtshadcn/common/helpers.py +++ b/qtshadcn/common/helpers.py @@ -6,7 +6,7 @@ from qtpy import QtGui, QtWidgets -from ..exceptions import QtShadcnError, ThemeParseError +from ..exceptions import QtShadcnError from ..models import ShadcnTheme, ShadcnThemeTokens from .theme_parser import resolve_value @@ -16,7 +16,7 @@ def _resolve_theme_file(theme_file: str | None) -> Path: - """Return the configured XML path or the packaged default theme path.""" + """Return the configured theme path or the packaged default theme path.""" if theme_file: return Path(theme_file) return DEFAULT_THEME_FILE @@ -31,14 +31,6 @@ def _resolve_application(app: QtWidgets.QApplication | None) -> QtWidgets.QAppli return app -def _get_mtime(path: Path) -> float: - """Return the modification time of the theme source file.""" - try: - return path.stat().st_mtime - except OSError as e: - raise ThemeParseError(f"Could not read theme source: {path}") from e - - def _add_fonts() -> None: """Register local font files found under the package ``fonts`` directory.""" if QtWidgets.QApplication.instance() is None: @@ -99,3 +91,8 @@ def _override_tokens(tokens: ShadcnThemeTokens, overrides: dict[str, str]) -> Sh continue data[key] = resolve_value(raw_value) return ShadcnThemeTokens(**data) + + +def _looks_like_jinja(content: str) -> bool: + """Return True when ``content`` contains Jinja delimiters.""" + return "{{" in content or "{%" in content diff --git a/qtshadcn/common/stylesheet.py b/qtshadcn/common/stylesheet.py new file mode 100644 index 0000000..b2e3352 --- /dev/null +++ b/qtshadcn/common/stylesheet.py @@ -0,0 +1,301 @@ +"""QtShadcn stylesheet rendering and three-concept theme API. + +This module owns the render pipeline and the public functions for controlling +mode, color palette, and additional QSS. +""" + +from __future__ import annotations + +import json +import logging +import shutil +from pathlib import Path +from typing import Any, cast + +import darkdetect +from qtpy import QtCore, QtWidgets + +from ..exceptions import QtShadcnError, ThemeParseError, ThemeRenderError +from ..models import ShadcnTheme, ShadcnThemeTokens +from .config import ThemeMode, _atomic_write, qsettings +from .helpers import _apply_custom_tokens, _looks_like_jinja, _resolve_theme_file +from .renderer import _build_theme +from .theme_parser import parse_theme_source + +logger = logging.getLogger(__name__) + +_default_theme: str = "dark" + + +def _resolve_is_dark(mode: ThemeMode, default_theme: str = "dark") -> bool: + """Resolve whether the active palette should be dark.""" + if mode == ThemeMode.AUTO: + detected = darkdetect.theme() + if detected == "Dark": + return True + if detected == "Light": + return False + return default_theme == "dark" + return mode == ThemeMode.DARK + + +def _load_theme_from_dir(cfg_dir: Path) -> ShadcnTheme | None: + """Load a persisted theme from ``cfg_dir`` (JSON primary, XML fallback).""" + theme_json = cfg_dir / "theme.json" + if theme_json.exists(): + try: + data = json.loads(theme_json.read_text(encoding="utf-8")) + data.pop("version", None) + return ShadcnTheme.model_validate(data) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Could not load theme.json from %s: %s", cfg_dir, e) + theme_xml = cfg_dir / "theme.xml" + if theme_xml.exists(): + try: + return parse_theme_source(theme_xml) + except ThemeParseError as e: + logger.warning("Could not load theme.xml from %s: %s", cfg_dir, e) + return None + + +def _active_theme() -> ShadcnTheme: + """Return the currently active theme, loading the default if needed.""" + if qsettings._theme is None: + loaded = _load_theme_from_dir(qsettings.config_dir()) + qsettings._theme = loaded or parse_theme_source(_resolve_theme_file(None)) + return qsettings._theme + + +def _render_and_apply(*, default_theme: str | None = None) -> None: + """Render the current theme and apply it to ``QApplication`` if it exists.""" + app = cast(Any, QtWidgets.QApplication.instance()) + if app is None: + return + + theme = _active_theme() + mode = ThemeMode(qsettings.theme_mode.value) + fallback = default_theme if default_theme is not None else _default_theme + is_dark = _resolve_is_dark(mode, fallback) + tokens = theme.dark if is_dark else theme.light + additional = qsettings.additional_style_sheet.value + + try: + stylesheet = _build_theme(tokens, is_dark=is_dark, additional_qss=additional) + app.setStyleSheet(stylesheet) + except ThemeRenderError: + logger.exception("Failed to render stylesheet") + raise + + +def _load_callback(_settings: Any) -> None: + """Apply loaded state by re-rendering the stylesheet.""" + _render_and_apply() + + +qsettings._on_load = _load_callback + + +def _normalize_theme_mode(mode: ThemeMode | str) -> ThemeMode: + """Validate and return a normalized ``ThemeMode`` value.""" + if isinstance(mode, ThemeMode): + return mode + try: + return ThemeMode(mode) + except ValueError as e: + raise QtShadcnError( + f"Invalid theme_mode {mode!r}; expected 'light', 'dark', or 'auto'" + ) from e + + +def _set_theme_mode( + mode: ThemeMode | str, *, save: bool = True, default_theme: str | None = None +) -> ThemeMode: + """Set the active theme mode and re-render the stylesheet. + + Args: + mode: One of ``ThemeMode.AUTO``, ``ThemeMode.LIGHT``, ``ThemeMode.DARK``, + or the equivalent string values. + save: When ``True`` (default), persist the mode to + ``config/theme_mode.json``. + default_theme: Optional fallback palette for ``auto`` mode when OS + detection fails. Used internally by deprecated wrappers. + + Returns: + The normalized ``ThemeMode`` that was applied. + + """ + mode_enum = _normalize_theme_mode(mode) + qsettings.theme_mode.set(mode_enum.value) + if save: + qsettings.save(only={"theme_mode"}) + _render_and_apply(default_theme=default_theme) + return mode_enum + + +def setThemeMode(mode: ThemeMode | str, *, save: bool = True) -> None: + """Set the active theme mode and re-render the stylesheet. + + Args: + mode: One of ``ThemeMode.AUTO``, ``ThemeMode.LIGHT``, ``ThemeMode.DARK``, + or the equivalent string values. + save: When ``True`` (default), persist the mode to + ``config/theme_mode.json``. + + """ + _set_theme_mode(mode, save=save) + + +def toggleThemeMode(*, save: bool = True) -> None: + """Cycle the theme mode: auto → light → dark → auto.""" + current = ThemeMode(qsettings.theme_mode.value) + order = [ThemeMode.AUTO, ThemeMode.LIGHT, ThemeMode.DARK] + next_mode = order[(order.index(current) + 1) % len(order)] + setThemeMode(next_mode, save=save) + + +def themeMode() -> ThemeMode: + """Return the active theme mode.""" + return ThemeMode(qsettings.theme_mode.value) + + +def isDarkTheme() -> bool: + """Return whether the resolved active palette is dark.""" + return _resolve_is_dark(themeMode(), _default_theme) + + +def _parse_json_theme(path: Path) -> ShadcnTheme: + """Load a QtShadcn JSON palette and return resolved light/dark tokens.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ThemeParseError(f"Invalid JSON in theme file {path}: {e}") from e + except OSError as e: + raise ThemeParseError(f"Could not read theme source: {path}") from e + + if not isinstance(data, dict): + raise ThemeParseError(f"Invalid JSON theme structure in {path}") + data.pop("version", None) + try: + return ShadcnTheme.model_validate(data) + except Exception as e: + raise ThemeParseError(f"Invalid JSON theme tokens in {path}: {e}") from e + + +def _parse_theme_source(source: str | Path) -> ShadcnTheme: + """Load a theme from an XML or JSON source.""" + path = Path(source) + suffix = path.suffix.lower() + if suffix == ".css": + raise ThemeParseError( + "Raw CSS theme files are not accepted by the core runtime; " + "convert to QtShadcn XML or JSON first" + ) + if suffix == ".json": + return _parse_json_theme(path) + if suffix == ".xml": + return parse_theme_source(path) + raise ThemeParseError(f"QtShadcn core only accepts .xml or .json theme files: {path}") + + +def setTheme( + source: str | Path | None = None, + *, + custom_tokens: dict[str, Any] | None = None, + save: bool = True, +) -> None: + """Set the active color palette and re-render the stylesheet. + + Args: + source: Path to a QtShadcn ``.xml`` or ``.json`` theme file. If + ``None`` (default), the bundled default theme is used. + custom_tokens: Optional in-memory token overrides. Shared overrides + apply to both palettes; per-mode overrides use ``light``/``dark`` + top-level keys. + save: When ``True`` (default), persist the palette to ``config/``. + + """ + path = _resolve_theme_file(source) if source is None else Path(source) + if not path.exists(): + raise ThemeParseError(f"Could not read theme source: {path}") + + theme = _parse_theme_source(path) + theme = _apply_custom_tokens(theme, custom_tokens) + + qsettings._theme = theme + qsettings.theme.set(str(path)) + + if save: + cfg_dir = qsettings.config_dir() + cfg_dir.mkdir(parents=True, exist_ok=True) + suffix = path.suffix.lower() + if suffix == ".xml": + dst_xml = cfg_dir / "theme.xml" + if path.resolve() != dst_xml.resolve(): + shutil.copy2(path, dst_xml) + _write_theme_json(cfg_dir, theme) + elif suffix == ".json": + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + raise ThemeParseError(f"Could not read theme source: {path}") from e + data["version"] = 1 + _atomic_write(cfg_dir / "theme.json", json.dumps(data, indent=2)) + else: + _write_theme_json(cfg_dir, theme) + + qsettings.themeChanged.emit() + _render_and_apply() + + +def _write_theme_json(cfg_dir: Path, theme: ShadcnTheme) -> None: + data = {"version": 1, **theme.model_dump()} + _atomic_write(cfg_dir / "theme.json", json.dumps(data, indent=2)) + + +def getTheme() -> ShadcnThemeTokens: + """Return the active palette tokens for the current mode.""" + theme = _active_theme() + return theme.dark if isDarkTheme() else theme.light + + +def _read_style_source(source: str | Path) -> str: + """Return the contents of ``source`` if it is a path, otherwise the string.""" + path = Path(source) + if path.exists(): + return path.read_text(encoding="utf-8") + return str(source) + + +def setStyleSheet(source: str | Path, *, save: bool = True) -> None: + """Set the additional stylesheet layered on top of the base QSS. + + Args: + source: Inline QSS/Jinja string or path to a ``.qss``/``.jinja`` file. + save: When ``True`` (default), persist to ``config/style.jinja`` or + ``config/style.qss``. + + """ + content = _read_style_source(source) + qsettings.additional_style_sheet.set(content) + + if save: + cfg_dir = qsettings.config_dir() + cfg_dir.mkdir(parents=True, exist_ok=True) + if _looks_like_jinja(content): + _atomic_write(cfg_dir / "style.jinja", content) + (cfg_dir / "style.qss").unlink(missing_ok=True) + else: + _atomic_write(cfg_dir / "style.qss", content) + (cfg_dir / "style.jinja").unlink(missing_ok=True) + + qsettings.additionalStyleSheetChanged.emit() + _render_and_apply() + + +def getStyleSheet() -> str: + """Return the current additional stylesheet content.""" + return qsettings.additional_style_sheet.value + + +# Keep QtCore.Qt alias import for type checkers that need Signal type reference. +_ = QtCore diff --git a/qtshadcn/common/theme.py b/qtshadcn/common/theme.py deleted file mode 100644 index e214327..0000000 --- a/qtshadcn/common/theme.py +++ /dev/null @@ -1,94 +0,0 @@ -"""QtShadcn theme application. - -Loads a native QtShadcn XML theme, resolves tokens, caches the resolved output, -renders QSS, and applies it to the Qt application. The core runtime only accepts -local XML themes. -""" - -import logging - -from qtpy import QtWidgets - -from ..exceptions import QtShadcnError -from ..models import ShadcnTheme, ShadcnThemeTokens, ThemeConfig -from .cache import _cache_hit, _load_theme_cache, _save_theme -from .helpers import _apply_custom_tokens, _get_mtime, _resolve_application, _resolve_theme_file -from .renderer import _build_theme -from .theme_mode import _normalize_theme_mode, _resolve_is_dark -from .theme_parser import parse_theme_source - -logger = logging.getLogger(__name__) - - -def apply_theme( - app: QtWidgets.QApplication | None = None, - theme_file: str | None = None, - *, - theme_mode: str = "auto", - custom_tokens: dict[str, dict[str, str] | str] | None = None, - additional_qss: str | None = None, - default_theme: str = "dark", -) -> ShadcnThemeTokens: - """Apply a QtShadcn XML theme and return the active palette. - - If ``app`` is ``None``, the existing ``QApplication.instance()`` is used. - If no instance exists, :class:`~qtshadcn.exceptions.QtShadcnError` is raised. - - If ``theme_file`` is ``None``, the packaged default XML theme is used. - ``theme_mode`` may be ``"light"``, ``"dark"`` or ``"auto"``. In ``"auto"`` - mode the OS preference is detected via ``darkdetect``; when detection fails, - ``default_theme`` is used. - - ``custom_tokens`` are applied after the XML is parsed and before the active - palette is selected. When the top-level keys are ``"light"`` and/or - ``"dark"`` the overrides are applied per palette; otherwise the same - overrides are applied to both palettes. - - ``additional_qss`` is appended to the base stylesheet. It may be an inline - Jinja snippet, a ``.qss`` file path, or a ``.jinja`` file path. - """ - app = _resolve_application(app) - mode = _normalize_theme_mode(theme_mode) - source_path = _resolve_theme_file(theme_file) - - if default_theme not in {"light", "dark"}: - raise QtShadcnError(f"Invalid default_theme {default_theme!r}; expected 'light' or 'dark'") - - config = ThemeConfig( - theme_mode=mode, - theme_file=str(source_path), - custom_tokens=custom_tokens, - additional_qss=additional_qss, - default_theme=default_theme, - ) - - saved_config, saved_theme, saved_mtime = _load_theme_cache() - current_mtime = _get_mtime(source_path) - - if _cache_hit(saved_config, saved_theme, saved_mtime, config, current_mtime): - logger.debug("Theme cache hit: %s", source_path) - theme = saved_theme - else: - logger.debug("Parsing theme: %s", source_path) - theme = parse_theme_source(source_path) - theme = _apply_custom_tokens(theme, custom_tokens) - _save_theme(config, theme, current_mtime) - - is_dark = _resolve_is_dark(mode, default_theme) - if theme is None: - raise QtShadcnError("Theme could not be loaded from cache or source") - active_tokens = theme.dark if is_dark else theme.light - mode_label = "dark" if is_dark else "light" - - stylesheet = _build_theme(active_tokens, is_dark=is_dark, additional_qss=additional_qss) - - app.setStyleSheet(stylesheet) - logger.info("Applied %s theme from: %s", mode_label, source_path) - - return active_tokens - - -def get_theme() -> ShadcnTheme | None: - """Return the resolved QtShadcn theme currently stored in cache.""" - _, saved_theme, _ = _load_theme_cache() - return saved_theme diff --git a/qtshadcn/common/theme_mode.py b/qtshadcn/common/theme_mode.py deleted file mode 100644 index 126a8bc..0000000 --- a/qtshadcn/common/theme_mode.py +++ /dev/null @@ -1,28 +0,0 @@ -"""QtShadcn theme mode resolution.""" - -import darkdetect - -from ..exceptions import QtShadcnError -from ..models import ThemeMode - - -def _normalize_theme_mode(theme_mode: str) -> ThemeMode: - """Validate and return a normalized ThemeMode value.""" - try: - return ThemeMode(theme_mode) - except ValueError as e: - raise QtShadcnError( - f"Invalid theme_mode {theme_mode!r}; expected 'light', 'dark', or 'auto'" - ) from e - - -def _resolve_is_dark(theme_mode: ThemeMode, default_theme: str) -> bool: - """Resolve whether the active palette should be dark.""" - if theme_mode == ThemeMode.AUTO: - detected = darkdetect.theme() - if detected == "Dark": - return True - if detected == "Light": - return False - return default_theme == "dark" - return theme_mode == ThemeMode.DARK diff --git a/qtshadcn/common/theme_watcher.py b/qtshadcn/common/theme_watcher.py new file mode 100644 index 0000000..cd2d6fd --- /dev/null +++ b/qtshadcn/common/theme_watcher.py @@ -0,0 +1,81 @@ +"""QtShadcn OS theme change listener. + +The listener runs in its own ``QThread``. On Windows it tries to use +``darkdetect.listener``; on other platforms it polls once per second. +""" + +from __future__ import annotations + +import logging +import sys +import time +from typing import Any + +import darkdetect +from qtpy import QtCore + +logger = logging.getLogger(__name__) + +_QThread: Any = QtCore.QThread + + +class SystemThemeListener(_QThread): + """Emit ``themeChanged("Dark"|"Light")`` when the OS theme changes. + + The listener lifecycle is manual: the caller constructs the instance, + connects to ``themeChanged``, calls ``start()``, and later calls + ``stop()``. + """ + + themeChanged = QtCore.Signal(str) + + def __init__( + self, + parent: QtCore.QObject | None = None, + *, + poll_interval_ms: int = 1000, + ) -> None: + """Create a listener with the requested polling fallback interval.""" + super().__init__(parent) + self._poll_interval_ms = poll_interval_ms + self._running = False + + def run(self) -> None: + """Start listening for OS theme changes. + + On Windows this attempts to use ``darkdetect.listener``; if that fails + or on other platforms it falls back to polling. + """ + self._running = True + if sys.platform == "win32": + try: + darkdetect.listener(self._on_theme_changed) + except Exception as e: + logger.warning("darkdetect listener failed, falling back to polling: %s", e) + self._poll_loop() + else: + self._poll_loop() + + def _poll_loop(self) -> None: + last_theme = darkdetect.theme() + while self._running: + current = darkdetect.theme() + if current != last_theme and current in {"Dark", "Light"}: + self._on_theme_changed(current) + last_theme = current + # Short sleeps make stop() responsive while keeping overhead low. + for _ in range(self._poll_interval_ms // 100): + if not self._running: + break + time.sleep(0.1) + + def _on_theme_changed(self, theme: str) -> None: + self.themeChanged.emit(theme) + + def stop(self) -> None: + """Request the listener thread to exit and wait for it to finish.""" + self._running = False + self.wait(5000) + + +_ = Any diff --git a/qtshadcn/models.py b/qtshadcn/models.py index a8886b5..8352b1f 100644 --- a/qtshadcn/models.py +++ b/qtshadcn/models.py @@ -1,44 +1,17 @@ """QtShadcn theme models. -Native XML-loaded theme tokens. Material models have been removed; the core -runtime only accepts local QtShadcn XML theme files. +Native XML/JSON-loaded theme tokens. Material models have been removed; the +runtime accepts local QtShadcn XML themes and JSON palettes. """ -from enum import StrEnum - from pydantic import BaseModel, ConfigDict -class ThemeMode(StrEnum): - """Theme mode selection.""" - - AUTO = "auto" - LIGHT = "light" - DARK = "dark" - - -class ThemeConfig(BaseModel): - """Internal theme configuration used for cache keys and persistence. - - The public :func:`~qtshadcn.common.theme.apply_theme` signature exposes these values - as keyword arguments. This frozen model is kept internal so the cache can be - keyed by every input that affects the rendered stylesheet. - """ - - model_config = ConfigDict(frozen=True, use_enum_values=True) - - theme_mode: ThemeMode = ThemeMode.AUTO - theme_file: str | None = None - custom_tokens: dict[str, dict[str, str] | str] | None = None - additional_qss: str | None = None - default_theme: str = "dark" - - class ShadcnThemeTokens(BaseModel): - """Resolved tokens for a single XML palette (light or dark). + """Resolved tokens for a single palette (light or dark). - Extra XML tokens are ignored so manually authored themes do not fail when - they contain additional variables. + Extra tokens are ignored so manually authored themes do not fail when they + contain additional variables. """ model_config = ConfigDict(extra="ignore", frozen=True) @@ -68,7 +41,7 @@ class ShadcnThemeTokens(BaseModel): class ShadcnTheme(BaseModel): - """Resolved light and dark palettes loaded from one QtShadcn XML document.""" + """Resolved light and dark palettes loaded from one QtShadcn theme document.""" model_config = ConfigDict(frozen=True) diff --git a/skills/commit-hygiene/SKILL.md b/skills/commit-hygiene/SKILL.md index 6da93a2..8b7be44 100644 --- a/skills/commit-hygiene/SKILL.md +++ b/skills/commit-hygiene/SKILL.md @@ -82,7 +82,7 @@ Validated by regex: ```text feat: add border-radius scale to design tokens -feat!: rename apply_theme() to load_theme() — breaking API change +feat!: remove deprecated apply_theme()/get_theme() wrappers fix: handle missing token fallback in QSS output refactor: extract token resolver to separate method docs: document token naming convention diff --git a/tests/test_app.py b/tests/test_app.py index 23a370a..0ffae9e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,8 +5,7 @@ import pytest from qtpy import QtWidgets -from qtshadcn.common.cache import _load_theme_cache -from qtshadcn.common.theme import apply_theme +from qtshadcn import getTheme, setStyleSheet, setTheme, setThemeMode from qtshadcn.exceptions import QtShadcnError, ThemeParseError SAMPLE_XML = """\ @@ -72,100 +71,78 @@ def sample_xml(tmp_path: Path) -> Path: class TestApplyTheme: - """Tests for the apply_theme function.""" + """Tests for the high-level theme application flow.""" def test_applies_theme_and_returns_active_palette( self, qapp: QtWidgets.QApplication, sample_xml: Path ): """Test that the theme is applied and the active palette is returned.""" - tokens = apply_theme(qapp, theme_file=str(sample_xml), theme_mode="light") + setThemeMode("light", save=False) + setTheme(sample_xml, save=False) + tokens = getTheme() assert tokens.background == "#ffffff" assert qapp.styleSheet() != "" def test_explicit_dark_palette(self, qapp: QtWidgets.QApplication, sample_xml: Path): """Test that the dark palette is applied.""" - tokens = apply_theme(qapp, theme_file=str(sample_xml), theme_mode="dark") + setThemeMode("dark", save=False) + setTheme(sample_xml, save=False) + tokens = getTheme() assert tokens.background == "#020617" def test_default_theme_applies_without_source_path(self, qapp: QtWidgets.QApplication): """Test that the default theme is applied without a source path.""" - tokens = apply_theme(qapp, theme_mode="light") + setThemeMode("light", save=False) + tokens = getTheme() assert tokens.background == "#ffffff" assert qapp.styleSheet() != "" - def test_apply_theme_without_app_uses_instance( - self, qapp: QtWidgets.QApplication, sample_xml: Path - ): - """Test that passing no app uses the existing QApplication instance.""" - tokens = apply_theme(theme_file=str(sample_xml), theme_mode="light") - assert tokens.background == "#ffffff" - assert qapp.styleSheet() != "" - - def test_apply_theme_without_app_and_no_instance_raises(self): - """Test that passing no app when no instance exists raises an error.""" - with ( - patch.object(QtWidgets.QApplication, "instance", return_value=None), - pytest.raises(QtShadcnError, match="No QApplication instance found"), - ): - apply_theme(theme_file="theme.xml", theme_mode="light") - def test_missing_file_raises_theme_parse_error(self, qapp: QtWidgets.QApplication): """Test that a missing file raises a ThemeParseError.""" with pytest.raises(ThemeParseError, match="Could not read theme source"): - apply_theme(qapp, theme_file="missing-theme.xml", theme_mode="light") + setTheme("missing-theme.xml") def test_auto_mode_selects_dark_when_detected( self, qapp: QtWidgets.QApplication, sample_xml: Path ): """Test that the auto mode selects dark when detected.""" - with patch("qtshadcn.common.theme_mode.darkdetect.theme", return_value="Dark"): - tokens = apply_theme(qapp, theme_file=str(sample_xml), theme_mode="auto") + setTheme(sample_xml, save=False) + with patch("qtshadcn.common.stylesheet.darkdetect.theme", return_value="Dark"): + setThemeMode("auto", save=False) + tokens = getTheme() assert tokens.background == "#020617" def test_auto_mode_selects_light_when_detected( self, qapp: QtWidgets.QApplication, sample_xml: Path ): """Test that the auto mode selects light when detected.""" - with patch("qtshadcn.common.theme_mode.darkdetect.theme", return_value="Light"): - tokens = apply_theme(qapp, theme_file=str(sample_xml), theme_mode="auto") + setTheme(sample_xml, save=False) + with patch("qtshadcn.common.stylesheet.darkdetect.theme", return_value="Light"): + setThemeMode("auto", save=False) + tokens = getTheme() assert tokens.background == "#ffffff" - def test_auto_mode_uses_default_theme_when_detection_fails( + def test_auto_mode_falls_back_to_dark_when_detection_fails( self, qapp: QtWidgets.QApplication, sample_xml: Path ): - """Test that auto mode falls back to default_theme when detection fails.""" - with patch("qtshadcn.common.theme_mode.darkdetect.theme", return_value=None): - tokens = apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="auto", - default_theme="light", - ) - assert tokens.background == "#ffffff" + """Test that auto mode falls back to dark when OS detection fails.""" + setTheme(sample_xml, save=False) + with patch("qtshadcn.common.stylesheet.darkdetect.theme", return_value=None): + setThemeMode("auto", save=False) + tokens = getTheme() + assert tokens.background == "#020617" def test_invalid_theme_mode_raises(self, qapp: QtWidgets.QApplication): """Test that an invalid theme_mode raises QtShadcnError.""" with pytest.raises(QtShadcnError, match="Invalid theme_mode"): - apply_theme(qapp, theme_mode="invalid") - - def test_invalid_default_theme_raises(self, qapp: QtWidgets.QApplication): - """Test that an invalid default_theme raises QtShadcnError.""" - with pytest.raises(QtShadcnError, match="Invalid default_theme"): - apply_theme(qapp, default_theme="invalid") - - def test_json_rejected(self, qapp: QtWidgets.QApplication, tmp_path: Path): - """Test that JSON files are rejected.""" - path = tmp_path / "theme.json" - path.write_text("{}", encoding="utf-8") - with pytest.raises(ThemeParseError, match="JSON"): - apply_theme(qapp, theme_file=str(path), theme_mode="light") + setThemeMode("invalid") def test_css_rejected(self, qapp: QtWidgets.QApplication, tmp_path: Path): """Test that CSS files are rejected.""" path = tmp_path / "theme.css" path.write_text("* { color: red; }", encoding="utf-8") with pytest.raises(ThemeParseError, match="CSS"): - apply_theme(qapp, theme_file=str(path), theme_mode="light") + setTheme(path) class TestCustomTokens: @@ -175,54 +152,51 @@ def test_shared_custom_tokens_apply_to_both_palettes( self, qapp: QtWidgets.QApplication, sample_xml: Path ): """Test that shared custom tokens apply to both palettes.""" - tokens = apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="light", - custom_tokens={"background": "#123456"}, - ) + setThemeMode("light", save=False) + setTheme(sample_xml, custom_tokens={"background": "#123456"}, save=False) + tokens = getTheme() assert tokens.background == "#123456" - tokens_dark = apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="dark", - custom_tokens={"background": "#123456"}, - ) + setThemeMode("dark", save=False) + setTheme(sample_xml, custom_tokens={"background": "#123456"}, save=False) + tokens_dark = getTheme() assert tokens_dark.background == "#123456" def test_mode_specific_custom_tokens(self, qapp: QtWidgets.QApplication, sample_xml: Path): """Test that mode-specific custom tokens apply per palette.""" - tokens = apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="light", + setThemeMode("light", save=False) + setTheme( + sample_xml, custom_tokens={ "light": {"background": "#abcdef"}, "dark": {"background": "#fedcba"}, }, + save=False, ) + tokens = getTheme() assert tokens.background == "#abcdef" - tokens_dark = apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="dark", + setThemeMode("dark", save=False) + setTheme( + sample_xml, custom_tokens={ "light": {"background": "#abcdef"}, "dark": {"background": "#fedcba"}, }, + save=False, ) + tokens_dark = getTheme() assert tokens_dark.background == "#fedcba" def test_custom_tokens_are_resolved(self, qapp: QtWidgets.QApplication, sample_xml: Path): """Test that custom token values are resolved before use.""" - tokens = apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="light", + setThemeMode("light", save=False) + setTheme( + sample_xml, custom_tokens={"background": "oklch(0.21 0.006 285.885)"}, + save=False, ) + tokens = getTheme() assert tokens.background.startswith("rgb(") @@ -233,12 +207,9 @@ def test_inline_additional_qss_is_appended( self, qapp: QtWidgets.QApplication, sample_xml: Path ): """Test that an inline additional_qss string is appended.""" - apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="light", - additional_qss="QWidget { color: red; }", - ) + setThemeMode("light", save=False) + setTheme(sample_xml, save=False) + setStyleSheet("QWidget { color: red; }", save=False) assert "QWidget { color: red; }" in qapp.styleSheet() def test_additional_qss_file_is_appended( @@ -248,74 +219,7 @@ def test_additional_qss_file_is_appended( qss_path = tmp_path / "extra.qss" qss_path.write_text("QWidget { color: blue; }", encoding="utf-8") - apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="light", - additional_qss=str(qss_path), - ) + setThemeMode("light", save=False) + setTheme(sample_xml, save=False) + setStyleSheet(qss_path, save=False) assert "QWidget { color: blue; }" in qapp.styleSheet() - - -class TestCache: - """Tests for the theme cache.""" - - def test_cache_stores_config_and_theme(self, qapp: QtWidgets.QApplication, sample_xml: Path): - """Test that the cache stores the config and theme.""" - apply_theme(qapp, theme_file=str(sample_xml), theme_mode="light") - - saved_config, saved_theme, saved_mtime = _load_theme_cache() - assert saved_config is not None - assert saved_theme is not None - assert saved_mtime is not None - assert saved_config.theme_file == str(sample_xml) - - def test_cache_reused_on_same_mtime(self, qapp: QtWidgets.QApplication, sample_xml: Path): - """Test that the cache is reused on the same mtime.""" - apply_theme(qapp, theme_file=str(sample_xml), theme_mode="light") - - with patch("qtshadcn.common.theme.parse_theme_source") as mock_parse: - apply_theme(qapp, theme_file=str(sample_xml), theme_mode="light") - mock_parse.assert_not_called() - - def test_cache_refreshed_on_source_change(self, qapp: QtWidgets.QApplication, sample_xml: Path): - """Test that the cache is refreshed when the source file changes.""" - apply_theme(qapp, theme_file=str(sample_xml), theme_mode="light") - - # Modify the source file to change its mtime. - sample_xml.write_text(SAMPLE_XML + "\n\n", encoding="utf-8") - - with patch("qtshadcn.common.theme.parse_theme_source") as mock_parse: - mock_parse.return_value = _load_theme_cache()[1] # cached theme - apply_theme(qapp, theme_file=str(sample_xml), theme_mode="light") - mock_parse.assert_called_once() - - def test_cache_includes_custom_tokens(self, qapp: QtWidgets.QApplication, sample_xml: Path): - """Test that changing custom_tokens invalidates the cache.""" - apply_theme(qapp, theme_file=str(sample_xml), theme_mode="light") - cached_theme = _load_theme_cache()[1] - - with patch("qtshadcn.common.theme.parse_theme_source") as mock_parse: - mock_parse.return_value = cached_theme - apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="light", - custom_tokens={"background": "#000000"}, - ) - mock_parse.assert_called_once() - - def test_cache_includes_additional_qss(self, qapp: QtWidgets.QApplication, sample_xml: Path): - """Test that changing additional_qss invalidates the cache.""" - apply_theme(qapp, theme_file=str(sample_xml), theme_mode="light") - cached_theme = _load_theme_cache()[1] - - with patch("qtshadcn.common.theme.parse_theme_source") as mock_parse: - mock_parse.return_value = cached_theme - apply_theme( - qapp, - theme_file=str(sample_xml), - theme_mode="light", - additional_qss="QWidget { color: green; }", - ) - mock_parse.assert_called_once() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..dc93a1f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,255 @@ +"""Tests for the QtShadcn persistent configuration system.""" + +import json +import warnings + +from qtpy import QtCore +from qtshadcn.common.config import ConfigItem, QtShadcnSettings, ThemeMode, qsettings +from qtshadcn.models import ShadcnTheme, ShadcnThemeTokens + + +def _tokens(**overrides): + values = { + "background": "#ffffff", + "foreground": "#020617", + "card": "#ffffff", + "card_foreground": "#020617", + "popover": "#ffffff", + "popover_foreground": "#020617", + "primary": "#0f172a", + "primary_foreground": "#f8fafc", + "secondary": "#f1f5f9", + "secondary_foreground": "#0f172a", + "muted": "#f1f5f9", + "muted_foreground": "#64748b", + "accent": "#f1f5f9", + "accent_foreground": "#0f172a", + "destructive": "#ef4444", + "destructive_foreground": "#f8fafc", + "border": "#e2e8f0", + "input": "#e2e8f0", + "ring": "#0f172a", + "spacing": "4px", + "radius": "8px", + "font_family": "system-ui, sans-serif", + } + values.update(overrides) + return values + + +class TestSingleton: + def test_qsettings_is_singleton(self): + from qtshadcn.common.config import qsettings as qsettings2 + + assert qsettings2 is qsettings + + +class TestConfigItem: + def test_default_value(self): + item = ConfigItem("mode", "auto") + assert item.value == "auto" + + def test_set_updates_value(self): + item = ConfigItem("mode", "auto") + item.set("dark") + assert item.value == "dark" + + def test_validator_rejects_invalid_value(self): + item = ConfigItem("mode", "auto", lambda v: v in {"auto", "light", "dark"}) + item.set("invalid") + assert item.value == "auto" + + def test_block_signal_prevents_emit(self, qtbot): + item = ConfigItem("mode", "auto") + received = [] + item.valueChanged.connect(lambda v: received.append(v)) + item.set("dark", block_signal=True) + assert item.value == "dark" + assert received == [] + + +class TestConfigDir: + def test_default_config_dir_uses_app_data_location(self, monkeypatch, tmp_path): + monkeypatch.setattr( + QtCore.QStandardPaths, + "writableLocation", + lambda _loc: str(tmp_path), + ) + qsettings.reset_for_test() + assert qsettings.config_dir() == tmp_path / "config" + + def test_custom_config_dir(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path / "custom") + assert qsettings.config_dir() == tmp_path / "custom" + + +class TestLoadAndSave: + def test_load_restores_theme_mode(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + (tmp_path / "theme_mode.json").write_text(json.dumps({"mode": "dark"}), encoding="utf-8") + + qsettings.load() + + assert qsettings.theme_mode.value == "dark" + + def test_load_restores_theme_from_json(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + theme_data = { + "version": 1, + "light": _tokens(), + "dark": _tokens(background="#000000"), + } + (tmp_path / "theme.json").write_text(json.dumps(theme_data), encoding="utf-8") + + qsettings.load() + + assert qsettings._theme is not None + assert qsettings._theme.dark.background == "#000000" + assert qsettings.theme.value == str(tmp_path / "theme.json") + + def test_load_restores_theme_from_xml(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + xml = """\ + + + #ffffff + #020617 + #ffffff + #020617 + #ffffff + #020617 + #0f172a + #f8fafc + #f1f5f9 + #0f172a + #f1f5f9 + #64748b + #f1f5f9 + #0f172a + #ef4444 + #f8fafc + #e2e8f0 + #e2e8f0 + #0f172a + 4px + 8px + system-ui, sans-serif + + + #020617 + #f8fafc + #020617 + #f8fafc + #020617 + #f8fafc + #f8fafc + #0f172a + #1e293b + #f8fafc + #1e293b + #94a3b8 + #1e293b + #f8fafc + #7f1d1d + #f8fafc + #1e293b + #1e293b + #cbd5e1 + 4px + 8px + system-ui, sans-serif + + +""" + (tmp_path / "theme.xml").write_text(xml, encoding="utf-8") + + qsettings.load() + + assert qsettings._theme is not None + assert qsettings._theme.light.background == "#ffffff" + assert qsettings.theme.value == str(tmp_path / "theme.xml") + + def test_load_restores_style_sheet(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + (tmp_path / "style.qss").write_text("QWidget { color: red; }", encoding="utf-8") + + qsettings.load() + + assert qsettings.additional_style_sheet.value == str(tmp_path / "style.qss") + + def test_load_with_config_dir_argument(self, tmp_path): + qsettings.reset_for_test() + (tmp_path / "theme_mode.json").write_text(json.dumps({"mode": "light"}), encoding="utf-8") + + qsettings.load(config_dir=tmp_path) + + assert qsettings.theme_mode.value == "light" + assert qsettings.config_dir() == tmp_path + + def test_load_with_custom_settings_instance(self, tmp_path): + custom = QtShadcnSettings() + (tmp_path / "theme_mode.json").write_text(json.dumps({"mode": "dark"}), encoding="utf-8") + + qsettings.load(config_dir=tmp_path, settings=custom) + + assert custom.theme_mode.value == "dark" + assert custom.config_dir() == tmp_path + + def test_save_writes_theme_mode_atomically(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + qsettings.theme_mode.set("dark") + + qsettings.save(only={"theme_mode"}) + + path = tmp_path / "theme_mode.json" + assert path.exists() + assert json.loads(path.read_text(encoding="utf-8")) == {"mode": "dark"} + assert not (tmp_path / "theme_mode.json.tmp").exists() + + def test_save_writes_theme_and_style(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + qsettings._theme = ShadcnTheme( + light=ShadcnThemeTokens(**_tokens()), + dark=ShadcnThemeTokens(**_tokens(background="#000000")), + ) + qsettings.additional_style_sheet.set("QWidget { color: blue; }") + + qsettings.save(only={"theme", "additional_style_sheet"}) + + theme_path = tmp_path / "theme.json" + assert theme_path.exists() + data = json.loads(theme_path.read_text(encoding="utf-8")) + assert data["version"] == 1 + assert data["dark"]["background"] == "#000000" + style_path = tmp_path / "style.qss" + assert style_path.exists() + assert style_path.read_text(encoding="utf-8") == "QWidget { color: blue; }" + + def test_corrupt_theme_mode_json_falls_back_to_auto(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + (tmp_path / "theme_mode.json").write_text("not json", encoding="utf-8") + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + qsettings.load() + + assert qsettings.theme_mode.value == ThemeMode.AUTO.value + assert any("Corrupt theme_mode.json" in str(warning.message) for warning in w) + + def test_save_jinja_style_sheet(self, tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + qsettings.additional_style_sheet.set("QWidget { color: {{ tokens.primary }}; }") + + qsettings.save(only={"additional_style_sheet"}) + + assert (tmp_path / "style.jinja").exists() + assert not (tmp_path / "style.qss").exists() diff --git a/tests/test_integration.py b/tests/test_integration.py index c71f9d8..b96af00 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,8 +4,8 @@ import pytest from qtpy import QtWidgets +from qtshadcn import getTheme, setTheme, setThemeMode from qtshadcn.common.renderer import _build_theme -from qtshadcn.common.theme import apply_theme from qtshadcn.models import ShadcnThemeTokens FULL_THEME = """\ @@ -75,7 +75,9 @@ class TestIntegration: def test_full_pipeline(self, qapp: QtWidgets.QApplication, full_theme_path: Path): """Test the full theme application pipeline.""" - tokens = apply_theme(qapp, theme_file=str(full_theme_path), theme_mode="light") + setThemeMode("light", save=False) + setTheme(full_theme_path, save=False) + tokens = getTheme() assert isinstance(tokens, ShadcnThemeTokens) assert tokens.background == "#ffffff" assert "rgb(" in tokens.primary @@ -85,7 +87,9 @@ def test_full_pipeline(self, qapp: QtWidgets.QApplication, full_theme_path: Path def test_qss_output_is_qt_safe(self, qapp: QtWidgets.QApplication, full_theme_path: Path): """Test that the QSS output is safe for Qt.""" - tokens = apply_theme(qapp, theme_file=str(full_theme_path), theme_mode="light") + setThemeMode("light", save=False) + setTheme(full_theme_path, save=False) + tokens = getTheme() qss = _build_theme(tokens) assert "oklch(" not in qss assert "rem" not in qss diff --git a/tests/test_models.py b/tests/test_models.py index 62851d0..96fd1c6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,35 +1,7 @@ """Tests for QtShadcn theme models.""" import pytest -from qtshadcn.models import ShadcnTheme, ShadcnThemeTokens, ThemeConfig - - -class TestThemeConfig: - """Tests for the internal theme config model.""" - - def test_default_config_has_no_source_path(self): - """Test that the default config has no source path.""" - config = ThemeConfig() - assert config.theme_file is None - assert config.theme_mode == "auto" - assert config.custom_tokens is None - assert config.additional_qss is None - assert config.default_theme == "dark" - - def test_config_stores_theme_file(self): - """Test that the config stores the theme file path.""" - config = ThemeConfig(theme_file="/tmp/theme.xml") - assert config.theme_file == "/tmp/theme.xml" - - def test_config_stores_custom_tokens(self): - """Test that the config stores custom token overrides.""" - config = ThemeConfig(custom_tokens={"background": "#000000"}) - assert config.custom_tokens == {"background": "#000000"} - - def test_config_stores_additional_qss(self): - """Test that the config stores additional QSS.""" - config = ThemeConfig(additional_qss="QWidget { color: red; }") - assert config.additional_qss == "QWidget { color: red; }" +from qtshadcn.models import ShadcnTheme, ShadcnThemeTokens class TestShadcnThemeTokens: diff --git a/tests/test_stylesheet.py b/tests/test_stylesheet.py new file mode 100644 index 0000000..024b24b --- /dev/null +++ b/tests/test_stylesheet.py @@ -0,0 +1,243 @@ +"""Tests for the QtShadcn stylesheet rendering and 3-concept API.""" + +import json +from unittest.mock import patch + +import pytest +from qtpy import QtWidgets +from qtshadcn.common.config import ThemeMode, qsettings +from qtshadcn.common.stylesheet import ( + getStyleSheet, + getTheme, + isDarkTheme, + setStyleSheet, + setTheme, + setThemeMode, + themeMode, + toggleThemeMode, +) + + +def _tokens(**overrides): + values = { + "background": "#ffffff", + "foreground": "#020617", + "card": "#ffffff", + "card_foreground": "#020617", + "popover": "#ffffff", + "popover_foreground": "#020617", + "primary": "#0f172a", + "primary_foreground": "#f8fafc", + "secondary": "#f1f5f9", + "secondary_foreground": "#0f172a", + "muted": "#f1f5f9", + "muted_foreground": "#64748b", + "accent": "#f1f5f9", + "accent_foreground": "#0f172a", + "destructive": "#ef4444", + "destructive_foreground": "#f8fafc", + "border": "#e2e8f0", + "input": "#e2e8f0", + "ring": "#0f172a", + "spacing": "4px", + "radius": "8px", + "font_family": "system-ui, sans-serif", + } + values.update(overrides) + return values + + +@pytest.fixture +def sample_xml(tmp_path): + path = tmp_path / "theme.xml" + path.write_text( + """\ + + + #ffffff + #020617 + #ffffff + #020617 + #ffffff + #020617 + #0f172a + #f8fafc + #f1f5f9 + #0f172a + #f1f5f9 + #64748b + #f1f5f9 + #0f172a + #ef4444 + #f8fafc + #e2e8f0 + #e2e8f0 + #0f172a + 4px + 8px + system-ui, sans-serif + + + #020617 + #f8fafc + #020617 + #f8fafc + #020617 + #f8fafc + #f8fafc + #0f172a + #1e293b + #f8fafc + #1e293b + #94a3b8 + #1e293b + #f8fafc + #7f1d1d + #f8fafc + #1e293b + #1e293b + #cbd5e1 + 4px + 8px + system-ui, sans-serif + + +""", + encoding="utf-8", + ) + return path + + +@pytest.fixture +def sample_json(tmp_path): + path = tmp_path / "theme.json" + data = {"version": 1, "light": _tokens(), "dark": _tokens(background="#000000")} + path.write_text(json.dumps(data), encoding="utf-8") + return path + + +@pytest.fixture(autouse=True) +def _reset_settings(tmp_path): + qsettings.reset_for_test() + qsettings.set_config_dir(tmp_path) + yield + qsettings.reset_for_test() + + +class TestThemeMode: + def test_set_theme_mode(self): + setThemeMode("dark") + assert themeMode() == ThemeMode.DARK + + def test_set_theme_mode_enum(self): + setThemeMode(ThemeMode.LIGHT) + assert themeMode() == ThemeMode.LIGHT + + def test_invalid_theme_mode_raises(self): + with pytest.raises(Exception, match="Invalid theme_mode"): + setThemeMode("invalid") + + def test_toggle_theme_mode(self): + setThemeMode(ThemeMode.AUTO) + toggleThemeMode() + assert themeMode() == ThemeMode.LIGHT + toggleThemeMode() + assert themeMode() == ThemeMode.DARK + toggleThemeMode() + assert themeMode() == ThemeMode.AUTO + + def test_is_dark_theme_resolves_dark(self): + setThemeMode(ThemeMode.DARK) + assert isDarkTheme() is True + + def test_is_dark_theme_resolves_light(self): + setThemeMode(ThemeMode.LIGHT) + assert isDarkTheme() is False + + def test_is_dark_theme_auto_detects_dark(self): + with patch("qtshadcn.common.stylesheet.darkdetect.theme", return_value="Dark"): + setThemeMode(ThemeMode.AUTO) + assert isDarkTheme() is True + + def test_theme_mode_persists(self, tmp_path): + setThemeMode(ThemeMode.DARK, save=True) + assert (tmp_path / "theme_mode.json").exists() + + +class TestSetTheme: + def test_set_xml_theme(self, qapp, sample_xml, tmp_path): + setThemeMode(ThemeMode.LIGHT) + setTheme(sample_xml, save=True) + tokens = getTheme() + assert tokens.background == "#ffffff" + assert (tmp_path / "theme.xml").exists() + assert (tmp_path / "theme.json").exists() + + def test_set_json_theme(self, qapp, sample_json, tmp_path): + setThemeMode(ThemeMode.LIGHT) + setTheme(sample_json, save=True) + tokens = getTheme() + assert tokens.background == "#ffffff" + assert (tmp_path / "theme.json").exists() + + def test_get_theme_respects_mode(self, qapp, sample_xml): + setThemeMode(ThemeMode.DARK) + setTheme(sample_xml) + tokens = getTheme() + assert tokens.background == "#020617" + + def test_set_theme_with_custom_tokens(self, qapp, sample_xml): + setThemeMode(ThemeMode.LIGHT) + setTheme(sample_xml, custom_tokens={"background": "#123456"}) + tokens = getTheme() + assert tokens.background == "#123456" + + def test_missing_theme_file_raises(self): + with pytest.raises(Exception, match="Could not read theme source"): + setTheme("missing-theme.xml") + + def test_css_theme_rejected(self, tmp_path): + path = tmp_path / "theme.css" + path.write_text("* { color: red; }", encoding="utf-8") + with pytest.raises(Exception, match="CSS"): + setTheme(path) + + +class TestSetStyleSheet: + def test_set_inline_qss(self, qapp): + setStyleSheet("QWidget { color: red; }") + assert getStyleSheet() == "QWidget { color: red; }" + assert "QWidget { color: red; }" in qapp.styleSheet() + + def test_set_qss_file(self, qapp, tmp_path): + path = tmp_path / "extra.qss" + path.write_text("QWidget { color: blue; }", encoding="utf-8") + setStyleSheet(path, save=True) + assert "QWidget { color: blue; }" in qapp.styleSheet() + assert (tmp_path / "style.qss").exists() + + def test_set_jinja_file(self, qapp, tmp_path): + path = tmp_path / "extra.jinja" + path.write_text("QWidget { color: {{ tokens.primary }}; }", encoding="utf-8") + setStyleSheet(path, save=True) + assert (tmp_path / "style.jinja").exists() + assert not (tmp_path / "style.qss").exists() + + def test_set_inline_jinja_detected(self, qapp, tmp_path): + setStyleSheet("QWidget { color: {{ tokens.primary }}; }", save=True) + assert (tmp_path / "style.jinja").exists() + + +class TestRendering: + def test_set_theme_mode_renders_qss(self, qapp, sample_xml): + setTheme(sample_xml) + before = qapp.styleSheet() + setThemeMode(ThemeMode.DARK) + after = qapp.styleSheet() + assert after != before or "#020617" in after + + def test_no_application_is_no_op(self, monkeypatch, sample_xml): + monkeypatch.setattr(QtWidgets.QApplication, "instance", lambda: None) + setThemeMode(ThemeMode.DARK) + setTheme(sample_xml) + setStyleSheet("QWidget { color: red; }") diff --git a/tests/test_theme_watcher.py b/tests/test_theme_watcher.py new file mode 100644 index 0000000..9312ac3 --- /dev/null +++ b/tests/test_theme_watcher.py @@ -0,0 +1,174 @@ +"""Tests for the QtShadcn OS theme listener.""" + +from unittest.mock import patch + +import pytest +from qtshadcn.common.config import ThemeMode, qsettings +from qtshadcn.common.stylesheet import setThemeMode, themeMode +from qtshadcn.common.theme_watcher import SystemThemeListener + + +@pytest.fixture(autouse=True) +def _reset_settings(): + qsettings.reset_for_test() + yield + qsettings.reset_for_test() + + +def _theme_sequence(*values): + """Return a callable that yields ``values`` and then repeats the last one.""" + state = {"index": 0} + + def theme(): + if state["index"] < len(values): + value = values[state["index"]] + state["index"] += 1 + return value + return values[-1] + + return theme + + +def _listener_raises(_callback): + raise RuntimeError("listener unavailable") + + +class TestLifecycle: + def test_manual_start_stop(self): + listener = SystemThemeListener(poll_interval_ms=50) + with ( + patch( + "qtshadcn.common.theme_watcher.darkdetect.listener", + side_effect=_listener_raises, + ), + patch( + "qtshadcn.common.theme_watcher.darkdetect.theme", + return_value="Light", + ), + ): + listener.start() + listener.wait(1000) + listener.stop() + assert listener.isRunning() is False + + def test_stop_exits_polling_loop(self): + listener = SystemThemeListener(poll_interval_ms=50) + with ( + patch( + "qtshadcn.common.theme_watcher.darkdetect.listener", + side_effect=_listener_raises, + ), + patch( + "qtshadcn.common.theme_watcher.darkdetect.theme", + return_value="Light", + ), + ): + listener.start() + listener.wait(1000) + listener.stop() + assert listener.isRunning() is False + + +class TestSignalEmission: + def test_signal_emitted_on_theme_change(self, qtbot): + listener = SystemThemeListener(poll_interval_ms=50) + collected = [] + listener.themeChanged.connect(collected.append) + + with ( + patch( + "qtshadcn.common.theme_watcher.darkdetect.listener", + side_effect=_listener_raises, + ), + patch( + "qtshadcn.common.theme_watcher.darkdetect.theme", + side_effect=_theme_sequence("Light", "Dark"), + ), + ): + listener.start() + qtbot.wait(1000) + listener.stop() + + assert "Dark" in collected + + def test_polling_fallback_emits_signal(self, qtbot): + listener = SystemThemeListener(poll_interval_ms=50) + collected = [] + listener.themeChanged.connect(collected.append) + + with ( + patch( + "qtshadcn.common.theme_watcher.darkdetect.listener", + side_effect=_listener_raises, + ), + patch( + "qtshadcn.common.theme_watcher.darkdetect.theme", + side_effect=_theme_sequence("Light", "Light", "Dark"), + ), + ): + listener.start() + qtbot.wait(1000) + listener.stop() + + assert "Dark" in collected + + def test_polling_fallback_used_on_non_windows(self, qtbot): + listener = SystemThemeListener(poll_interval_ms=50) + collected = [] + listener.themeChanged.connect(collected.append) + + with ( + patch( + "qtshadcn.common.theme_watcher.sys.platform", + "darwin", + ), + patch( + "qtshadcn.common.theme_watcher.darkdetect.theme", + side_effect=_theme_sequence("Light", "Dark"), + ), + ): + listener.start() + qtbot.wait(1000) + listener.stop() + + assert "Dark" in collected + + +class TestAutoModeOnly: + def test_mode_flips_only_when_auto(self, qapp): + setThemeMode(ThemeMode.AUTO) + listener = SystemThemeListener() + received = [] + + def on_change(theme): + if themeMode() == ThemeMode.AUTO: + setThemeMode( + ThemeMode.DARK if theme == "Dark" else ThemeMode.LIGHT, + save=False, + ) + received.append(theme) + + listener.themeChanged.connect(on_change) + listener._on_theme_changed("Dark") + + assert received == ["Dark"] + assert themeMode() == ThemeMode.DARK + + def test_mode_does_not_flip_when_manual(self, qapp): + setThemeMode(ThemeMode.LIGHT) + listener = SystemThemeListener() + received = [] + + def on_change(theme): + if themeMode() == ThemeMode.AUTO: + setThemeMode( + ThemeMode.DARK if theme == "Dark" else ThemeMode.LIGHT, + save=False, + ) + received.append(theme) + + listener.themeChanged.connect(on_change) + listener._on_theme_changed("Dark") + + assert received == ["Dark"] + assert themeMode() == ThemeMode.LIGHT diff --git a/tests/test_version.py b/tests/test_version.py index 033bc91..e58ff67 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -4,20 +4,14 @@ import importlib.abc import importlib.metadata import sys -import tomllib -from pathlib import Path import pytest import qtshadcn -PROJECT_ROOT = Path(__file__).resolve().parent.parent - -def _pyproject_version() -> str: - """Return the project version declared in pyproject.toml.""" - with (PROJECT_ROOT / "pyproject.toml").open("rb") as pyproject_file: - pyproject = tomllib.load(pyproject_file) - return str(pyproject["project"]["version"]) +def _source_version() -> str: + """Return the package version from the source module.""" + return str(qtshadcn.__version__) def _expected_version() -> str: @@ -25,7 +19,7 @@ def _expected_version() -> str: try: return importlib.metadata.version("qtshadcn") except importlib.metadata.PackageNotFoundError: - return _pyproject_version() + return _source_version() def test_version_matches_available_project_version(): @@ -66,29 +60,8 @@ def isolated_qtshadcn_import(): def test_import_raises_qt_binding_error_without_qt_binding( isolated_qtshadcn_import: None, ): - """Importing apply_theme without a Qt binding raises QtBindingError.""" - sys.meta_path.insert(0, _BlockQtBindings()) - - from qtshadcn import QtBindingError - - with pytest.raises(QtBindingError): - from qtshadcn import apply_theme # noqa: F401 - - -def test_version_falls_back_to_pyproject_without_metadata_or_qt_import( - isolated_qtshadcn_import: None, monkeypatch: pytest.MonkeyPatch -): - """Test that source checkout imports still expose a version without Qt imports.""" - original_version = importlib.metadata.version - - def missing_metadata(name: str) -> str: - if name == "qtshadcn": - raise importlib.metadata.PackageNotFoundError(name) - return original_version(name) - - monkeypatch.setattr(importlib.metadata, "version", missing_metadata) + """Importing qtshadcn without a Qt binding raises QtBindingError.""" sys.meta_path.insert(0, _BlockQtBindings()) - import qtshadcn - - assert qtshadcn.__version__ == _pyproject_version() + with pytest.raises(ImportError, match="No supported Qt binding found"): + import qtshadcn # noqa: F401