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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 6 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ QtShadcn loads a local **XML theme file** with `<light>` and `<dark>` 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)
│ ├── cache.py # Theme cache persistence
│ ├── 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/
Expand Down Expand Up @@ -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

---

Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
67 changes: 48 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!")
Expand Down Expand Up @@ -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`

Expand Down
143 changes: 106 additions & 37 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

---

Expand Down Expand Up @@ -119,23 +187,24 @@ 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 |
| --- | --- |
| `"auto"` | Detects OS light/dark preference via `darkdetect` (default) |
| `"light"` | Always uses the `<light>` palette |
| `"dark"` | Always uses the `<dark>` 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.

---

## Models

### `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"`).

Expand Down Expand Up @@ -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 |
| --- | --- |
Expand All @@ -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:

Expand All @@ -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}")
```
Loading
Loading