diff --git a/AGENTS.md b/AGENTS.md
index abafb1d..8eb7ed6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,9 +39,9 @@ qtshadcn/
├── themes/
│ └── default.xml # Default light/dark token values
├── tokens/
-│ ├── colors.py # alpha(), mix() helpers
-│ ├── radius.py # class_px() helper
-│ └── scale.py # spacing_px(), spacing_int(), TEXT_*, FONT_WEIGHT_* constants
+│ ├── colors.py # withAlpha(), blendColors() helpers
+│ ├── radius.py # resolveRadius() helper
+│ └── scale.py # resolveSpacing(), toSpacingInt()
└── fonts/ # Bundled font files
examples/
└── gallery/ # Modular widget gallery with live theme editor
@@ -100,13 +100,107 @@ pre-commit run renovate-config-validator # validate Renovate config
- **Type annotations**: required on all public functions; `ty` must pass with no warnings
- **Tests**: no docstrings required in `tests/` or `examples/`
+### Naming Conventions
+
+> Ruff does not enforce naming style in this project (`N` rules are not selected). These rules are binding for all agents.
+
+| Symbol | Style | Example |
+| --- | --- | --- |
+| Functions, methods, variables, parameters | `camelCase` | `resolveRadius`, `baseRadius` |
+| Private functions / methods | `_camelCase` (single leading underscore) | `_parsePixel`, `_toPixelString` |
+| Classes | `PascalCase` | `ShadcnTheme`, `ThemeMode` |
+| Global constants | `UPPER_SNAKE_CASE` | `TEXT_SM`, `FONT_WEIGHT_BOLD` |
+
+**No abbreviations.** Names must be self-explanatory without context.
+
+- ❌ `addPx`, `calcBtn`, `ctx`, `val`, `r`
+- ✅ `expandPixel`, `calculateButton`, `context`, `numericValue`, `baseRadius`
+
+**Functions and methods must be verb + context** (at least two words).
+
+- ❌ `process()`, `radius()`, `data()`
+- ✅ `processInvoice()`, `resolveRadius()`, `fetchThemeData()`
+
+The following example shows all rules in action:
+
+```python
+# Public: camelCase, verb + context, no abbreviations
+def calculateTotalPrice(unitPrice: float, quantity: int) -> float: ...
+def fetchUserProfile(userId: str) -> UserProfile: ...
+def formatDisplayName(firstName: str, lastName: str) -> str: ...
+
+
+# Private: _camelCase, same verb + context rule applies
+def _parseRawResponse(responseBody: str) -> dict: ...
+def _buildRequestPayload(eventType: str, metadata: dict) -> dict: ...
+```
+
+### Comment Style
+
+- Comment the **WHY** or the **HOW**, never the obvious **WHAT** (e.g., never `# adds two numbers` above `a + b`)
+- **Forbidden**: docstrings that just restate parameter names without real context
+- **Forbidden**: exhaustive `Args: / Returns: / Raises:` blocks on trivial functions (getters/setters, simple wrappers, functions < 5 lines with obvious behavior)
+- **Forbidden**: filler phrases (`"This function is responsible for..."`, `"Esta función se encarga de..."`)
+- Only comment where the code doesn't explain itself: non-obvious decisions, workarounds, temporary hacks, trade-offs
+- Adapt documentation depth to complexity — trivial functions need minimal docs, complex ones need full context
+
+**Approved docstring format** (Google style — omit sections that add no value):
+
+```python
+def example(parametro1: int, parametro2: str = "default") -> str:
+ """
+ Brief one-line summary of what the function does.
+
+ Longer description only when needed — context, behaviour, or edge cases
+ that the signature and body don't make obvious.
+
+ Args:
+ parametro1 (int): What it represents and why it matters, not just its name.
+ parametro2 (str, optional): When the default is surprising or has side-effects
+ worth noting (default: "default").
+
+ Returns:
+ str: What the return value means, not just its type.
+
+ Raises:
+ ValueError: Only when the condition that triggers it is non-obvious.
+
+ Examples:
+ >>> example(10)
+ 'expected result'
+ """
+```
+
+```python
+class TrainingModel:
+ """
+ Represents a machine learning model in training.
+
+ Attributes:
+ parametro1 (str): The identifier name of the model.
+ parametro2 (int): Number of epochs completed so far.
+ """
+
+ def __init__(self, parametro1: str, parametro2: int = 0):
+ self.parametro1 = parametro1
+ self.parametro2 = parametro2
+```
+
+**When to include each section:**
+
+- `Args:` — always for parameters with non-obvious meaning or constraints
+- `Returns:` — when the return value semantics aren't clear from the type
+- `Raises:` — only for exceptions that callers need to handle
+- `Examples:` — for complex functions, non-obvious usage, or public API
+- Longer description — only when the one-liner isn't sufficient
+
---
## Architecture Rules
- **Widget styles belong in `qtshadcn/styles/shadcn.jinja` only** — do not create new QSS files
- **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
+- **Token helpers** (`Colors.withAlpha`, `Colors.blendColors`, `radius.resolveRadius`, `Scale.resolveSpacing`) 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**: `qsettings`, `ThemeMode`, `setThemeMode()`, `toggleThemeMode()`,
`themeMode()`, `isDarkTheme()`, `setTheme()`, `getTheme()`, `setStyleSheet()`,
@@ -121,6 +215,7 @@ Follows [semver](https://semver.org). Version is defined in `pyproject.toml` and
- `feat!:` commits → MAJOR bump
- `feat:` commits → MINOR bump
- `fix:` commits → PATCH bump
+
## Available Skills
Use these skills for detailed patterns on-demand:
@@ -128,7 +223,7 @@ Use these skills for detailed patterns on-demand:
### Generic Skills (Any Project)
| Skill | Description | URL |
-|-------|-------------|-----|
+| ------- | ------------- | ----- |
| `commit-hygiene` | Trigger: commit, git commit, conventional commit, branch cleanup. Enforce conventional commits and publishing approval for QtShadcn. | [SKILL.md](skills/commit-hygiene/SKILL.md) |
| `pytest` | Trigger: pytest tests, pytest coverage, fixtures, mocking, markers, parametrize, test discovery. Write idiomatic Python pytest tests and test helpers. | [SKILL.md](skills/pytest/SKILL.md) |
| `skill-creator` | Trigger: new skills, agent instructions, documenting AI usage patterns. Create LLM-first skills with valid frontmatter. | [SKILL.md](skills/skill-creator/SKILL.md) |
@@ -136,17 +231,17 @@ Use these skills for detailed patterns on-demand:
### QtShadcn Specific Skills
-| Skill | Description | URL |
-|-------|-------------|-----|
-| `release-notes` | Trigger: release, release notes, changelog, tag, version. Run the QtShadcn release process: draft notes, verify version, create tag and GitHub Release. | [SKILL.md](skills/release-notes/SKILL.md) |
-| `skill-sync` | Syncs skill metadata to AGENTS.md Available Skills sections. Trigger: When updating skill metadata (metadata.scope/metadata.auto_invoke), regenerating Available Skills tables, or running ./skills/skill-sync/assets/sync.py (including --dry-run/--scope). | [SKILL.md](skills/skill-sync/SKILL.md) |
+| Skill | Description | URL |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- |
+| `release-notes` | Trigger: release, release notes, changelog, tag, version. Run the QtShadcn release process: draft notes, verify version, create tag and GitHub Release. | [SKILL.md](skills/release-notes/SKILL.md) |
+| `skill-sync` | Syncs skill metadata to AGENTS.md Available Skills sections. Trigger: When updating skill metadata (metadata.scope/metadata.auto_invoke), regenerating Available Skills tables, or running ./skills/skill-sync/assets/sync.py (including --dry-run/--scope). | [SKILL.md](skills/skill-sync/SKILL.md) |
### Auto-invoke Skills
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
-|--------|-------|
+| -------- | ------- |
| After creating/modifying a skill | `skill-sync` |
| Auditing or improving existing skills | `skill-improver` |
| Checking pytest coverage or test discovery | `pytest` |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abbe246..9abb165 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
+# QtShadcn v0.4.0
+
+## Changes
+
+- Modernized theme engine internals.
+- Modernized token helpers and added typography tokens.
+- Updated default theme and QSS template.
+- Consolidated font bundles with Inter, updated Roboto, and removed italic/condensed variants.
+- Updated guides and examples.
+
+## Verification
+
+```bash
+pip install qtshadcn==0.4.0
+python -c "import qtshadcn; print(qtshadcn.__version__)"
+make test
+```
+
+---
+
+# QtShadcn v0.3.0
+
+## Breaking Changes
+
+- Removed `apply_theme()` and `get_theme()` from the public API; use `setTheme()` and `getTheme()` instead.
+- Removed the internal `cache.py` module; theme caching is no longer part of the public contract.
+
+## What's New
+
+- Added a persistent config system via a `qsettings` singleton for storing theme preferences across sessions.
+- Added a 3-concept stylesheet API with a full rendering pipeline (`setStyleSheet`, `getStyleSheet`, `setTheme`, `getTheme`).
+- Added `SystemThemeListener` for automatic OS-level light/dark theme reactivity.
+- Exported all new public symbols from the package root.
+
+## Fixes
+
+- Made `toggleThemeMode()` deterministic when no mode is set.
+- Set light mode explicitly in XML theme tests to avoid environment-dependent failures.
+
+## Verification
+
+```bash
+pip install qtshadcn==0.3.0
+python -c "import qtshadcn; print(qtshadcn.__version__)"
+make test
+```
+
+---
+
# QtShadcn v0.2.0
+
## Breaking Changes
- Migrated the Qt binding layer from the internal `qtshadcn/common/binding.py` shim to `qtpy>=2.4.0`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 96ab0ba..8b1a6a7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -66,7 +66,7 @@ To keep the project maintainable, follow these rules when making changes:
- **Widget styles belong in `qtshadcn/styles/shadcn.jinja` only** — do not create new QSS files.
- **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.
+- **Token helpers** (`Colors.withAlpha`, `Colors.blendColors`, `radius.resolveRadius`, `Scale.resolveSpacing`) 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**: `qsettings`, `ThemeMode`, `setThemeMode()`, `toggleThemeMode()`,
`themeMode()`, `isDarkTheme()`, `setTheme()`, `getTheme()`, `setStyleSheet()`,
diff --git a/README.md b/README.md
index 20ae7da..294a863 100644
--- a/README.md
+++ b/README.md
@@ -147,7 +147,7 @@ A QtShadcn theme is a plain XML file with two palette sections:
#e2e8f0
#0f172a
8px
- system-ui, sans-serif
+ Open Sans
4px
#ffffff
#020617
diff --git a/docs/api-reference.md b/docs/api-reference.md
index c3c719c..bc967bf 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -232,7 +232,7 @@ The example values below come from the packaged `default.xml` light palette. The
| `input` | `#e2e8f0` |
| `ring` | `#0f172a` |
| `radius` | `8px` |
-| `font_family` | `system-ui, sans-serif` |
+| `font_family` | `Open Sans` |
| `spacing` | `4px` |
---
diff --git a/docs/getting-started.md b/docs/getting-started.md
index f31fe13..3e12547 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -65,7 +65,7 @@ Save this as `my_theme.xml` next to your script:
#e2e8f0
#0f172a
8px
- system-ui, sans-serif
+ Open Sans
4px
#ffffff
#020617
@@ -89,7 +89,7 @@ Save this as `my_theme.xml` next to your script:
#1e293b
#cbd5e1
8px
- system-ui, sans-serif
+ Open Sans
4px
#020617
#f8fafc
@@ -143,7 +143,7 @@ tokens = getTheme()
QtShadcn includes **Open Sans** and **Roboto** under `qtshadcn/fonts/`. To activate them, set `font_family` in your XML:
```xml
-Open Sans, system-ui, sans-serif
+Open Sans
```
The fonts are registered automatically when the stylesheet is rendered.
diff --git a/docs/theme-format.md b/docs/theme-format.md
index 2ab70d6..5e5952c 100644
--- a/docs/theme-format.md
+++ b/docs/theme-format.md
@@ -48,53 +48,34 @@ Every palette section must contain **all 22 tokens** listed below. If any are mi
---
-## Supported Color Formats
+## Supported Token Values
-Token values can be expressed in any of the following formats. The runtime resolves them to `rgb()` or `rgba()` before injecting into QSS.
+Color and size values are used directly in the generated QSS, so they must be Qt-compatible.
-### Hex
+### Colors
+
+Use HEX colors:
```xml
#0f172a
```
-### RGB / RGBA
+`rgb()` and `rgba()` values are accepted as QSS-compatible passthrough values:
```xml
rgb(15, 23, 42)
rgba(15, 23, 42, 0.8)
```
-### HSL
-
-```xml
-hsl(222 47% 11%)
-hsl(222 47% 11% / 0.5)
-
-hsl(222 47% 11% / 50%)
-```
-
-### OKLCH
+### Sizes
-```xml
-oklch(13% 0.03 264)
-
-oklch(13% 0.03 264 / 0.8)
-
-oklch(0.13 0.03 264)
-```
-
-!!! note
- OKLCH is converted to `rgb()` at parse time. QSS never sees the raw `oklch()` value.
-
-### rem
+Use pixel values for `radius` and `spacing`:
```xml
-0.5rem
+8px
+4px
```
-Converted to `px` using `1rem = 16px`. `0.5rem` becomes `8px`.
-
---
## Fonts
@@ -104,14 +85,14 @@ QtShadcn ships with **Open Sans** and **Roboto** under `qtshadcn/fonts/`. They a
To use them, reference the family name in your theme:
```xml
-Open Sans, system-ui, sans-serif
+Open Sans
```
```xml
-Roboto, system-ui, sans-serif
+Open Sans
```
-The fallback chain (`system-ui, sans-serif`) ensures the app works even on systems where the fonts can't be loaded.
+The fallback chain (`sans-serif`) ensures the app works even on systems where the fonts can't be loaded.
---
@@ -126,20 +107,20 @@ The fallback chain (`system-ui, sans-serif`) ensures the app works even on syste
#020617
#ffffff
#020617
- oklch(13% 0.03 264)
+ #0f172a
#f8fafc
#f1f5f9
#0f172a
#f1f5f9
#0f172a
#f1f5f9
- hsl(215 16% 47%)
+ #64748b
#ef4444
#f8fafc
#e2e8f0
#e2e8f0
#0f172a
- 0.5rem
+ 8px
Open Sans, system-ui, sans-serif
4px
diff --git a/examples/gallery/custom.jinja b/examples/gallery/custom.jinja
index 6e6d34f..d6cf28a 100644
--- a/examples/gallery/custom.jinja
+++ b/examples/gallery/custom.jinja
@@ -1,5 +1,5 @@
#background {
color: {{ tokens.foreground }};
background-color: {{ tokens.background }};
- border-radius: {{ radius.class_px(tokens.radius, "xl") }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "xl") }};
}
diff --git a/examples/gallery/main.py b/examples/gallery/main.py
index 717734b..73d33d9 100644
--- a/examples/gallery/main.py
+++ b/examples/gallery/main.py
@@ -304,7 +304,7 @@ def _refresh_editor_widgets(self) -> None:
]:
raw = palette.get(name, "0px")
try:
- val = int(raw.replace("px", "").replace("rem", "").strip())
+ val = int(raw.replace("px", "").strip())
except ValueError:
val = 0
slider.blockSignals(True)
diff --git a/examples/gallery/theme.xml b/examples/gallery/theme.xml
index 5025ca2..c22eaeb 100644
--- a/examples/gallery/theme.xml
+++ b/examples/gallery/theme.xml
@@ -20,9 +20,9 @@
#e0e0f0
#e0e0f0
#6e56cf
- 0.25rem
- 0.5rem
- Open Sans, ui-sans-serif, sans-serif, system-ui
+ 4px
+ 8px
+ Open Sans
#0f0f1a
@@ -44,8 +44,8 @@
#303052
#303052
#a48fff
- 0.25rem
- 0.5rem
- Open Sans, ui-sans-serif, sans-serif, system-ui
+ 4px
+ 8px
+ Open Sans
diff --git a/examples/generator/style.css b/examples/generator/style.css
deleted file mode 100644
index e71449d..0000000
--- a/examples/generator/style.css
+++ /dev/null
@@ -1,173 +0,0 @@
-@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
index 054324d..208d8ef 100644
--- a/examples/generator/theme.xml
+++ b/examples/generator/theme.xml
@@ -19,9 +19,9 @@
#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'
+ 4px
+ 8px
+ Open Sans
#262624
@@ -43,8 +43,8 @@
#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'
+ 4px
+ 8px
+ Open Sans
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index d3565ea..2dba585 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -88,8 +88,8 @@ dev = [
"pytest-mock>=3.14.0", # mocking support for pytest
"pytest-qt>=4.2.0", # Qt widget testing (qtbot fixture)
"ruff>=0.16.2", # linter and formatter
- "pre-commit>=3.7", # git hooks
- "ty>=0.0.69", # type checker
+ "pre-commit>=4.6.2", # git hooks
+ "ty>=0.0.71", # type checker
"typer>=0.12.0", # CLI framework (skills setup script; Rich is bundled)
"pyyaml>=6.0", # YAML frontmatter parser for skills metadata sync
"questionary>=2.1.0", # interactive multi-select prompts for skills setup
@@ -137,10 +137,11 @@ testpaths = ["tests"]
[tool.ruff]
target-version = "py311"
line-length = 100
-
+exclude = [".venv"]
src = ["qtshadcn", "tests"]
[tool.ruff.lint]
+extend-select = ["D400"]
select = [
"D", # pydocstyle
"F", # Pyflakes
@@ -171,14 +172,17 @@ ignore = [
"D103", # missing function docstring
]
-[tool.ruff.format]
-quote-style = "double"
-indent-style = "space"
+[tool.ruff.lint.pydocstyle]
+convention = "google"
# ============================================================
# ty — type checker
# ============================================================
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+
[tool.ty.environment]
python-version = "3.11"
diff --git a/qtshadcn/__init__.py b/qtshadcn/__init__.py
index f0ae946..b5aabd7 100644
--- a/qtshadcn/__init__.py
+++ b/qtshadcn/__init__.py
@@ -3,7 +3,7 @@
from .exceptions import QtBindingError, QtShadcnError, ThemeParseError, ThemeRenderError
from .models import ShadcnTheme, ShadcnThemeTokens
-__version__ = "0.2.0"
+__version__ = "0.4.0"
try:
from qtpy import QtBindingsNotFoundError, QtModuleNotInstalledError
diff --git a/qtshadcn/common/config.py b/qtshadcn/common/config.py
index 79d6cdb..12be2b9 100644
--- a/qtshadcn/common/config.py
+++ b/qtshadcn/common/config.py
@@ -6,10 +6,9 @@
from __future__ import annotations
+import copy
import json
import logging
-import os
-import warnings
from collections.abc import Callable
from enum import StrEnum
from pathlib import Path
@@ -18,7 +17,7 @@
from qtpy import QtCore
from ..models import ShadcnTheme
-from .helpers import _looks_like_jinja
+from .helpers import _atomic_write, _looks_like_jinja
logger = logging.getLogger(__name__)
@@ -58,7 +57,7 @@ def __init__(
super().__init__(parent)
self._key = key
self._default = default
- self._value = default
+ self._value = copy.deepcopy(default)
self._validator = validator
@property
@@ -70,13 +69,15 @@ 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
+ if value == self._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 reset(self, *, block_signal: bool = False) -> None:
+ """Reset the value to the configured default and notify listeners."""
+ self.set(self._default, block_signal=block_signal)
def serialize(self) -> Any:
"""Return a JSON-serializable representation of the value."""
@@ -129,7 +130,7 @@ def __init__(self, parent: QtCore.QObject | None = None) -> None:
@staticmethod
def _validate_mode(value: Any) -> bool:
- return isinstance(value, str) and value in {m.value for m in ThemeMode}
+ return isinstance(value, str) and value in set(ThemeMode)
@staticmethod
def _validate_theme_source(value: Any) -> bool:
@@ -193,50 +194,24 @@ def _load_theme_mode(self, target: QtShadcnSettings, cfg_dir: Path) -> None:
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)
+ logger.warning("Corrupt theme_mode.json, resetting to auto: %s", e)
+ if not target.theme_mode.deserialize(mode):
+ target.theme_mode.reset(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)
-
+ theme, source = _load_theme_from_dir(cfg_dir)
target._theme = theme
- target.theme.set(source, block_signal=True)
+ target.theme.deserialize(source)
def _load_style_sheet(self, target: QtShadcnSettings, cfg_dir: Path) -> None:
style_jinja = cfg_dir / "style.jinja"
style_qss = cfg_dir / "style.qss"
- source = ""
+ content = ""
if style_jinja.exists():
- source = str(style_jinja)
+ content = style_jinja.read_text(encoding="utf-8")
elif style_qss.exists():
- source = str(style_qss)
- target.additional_style_sheet.set(source, block_signal=True)
+ content = style_qss.read_text(encoding="utf-8")
+ target.additional_style_sheet.deserialize(content)
def save(self, *, only: set[str] | None = None) -> None:
"""Persist current state to ``config_dir``.
@@ -254,16 +229,16 @@ def save(self, *, only: set[str] | None = None) -> None:
if "theme_mode" in keys:
_atomic_write(
cfg_dir / "theme_mode.json",
- json.dumps({"mode": self.theme_mode.value}, indent=2),
+ json.dumps({"mode": self.theme_mode.serialize()}, indent=2),
)
if "theme" in keys and self._theme is not None:
- data = {"version": 1, **self._theme.model_dump()}
+ data = 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
+ content = self.additional_style_sheet.serialize()
if _looks_like_jinja(content):
_atomic_write(cfg_dir / "style.jinja", content)
(cfg_dir / "style.qss").unlink(missing_ok=True)
@@ -280,15 +255,30 @@ def reset_for_test(self) -> 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 _load_theme_from_dir(cfg_dir: Path) -> tuple[ShadcnTheme | None, str]:
+ """Load a persisted theme from ``cfg_dir`` (JSON primary, XML fallback).
-def _settings_singleton() -> QtShadcnSettings:
- return QtShadcnSettings()
+ Returns:
+ A tuple of the parsed theme (or ``None`` if no valid theme was found)
+ and the source file path (empty string if no theme was found).
-
-qsettings: QtShadcnSettings = _settings_singleton()
+ """
+ theme_json = cfg_dir / "theme.json"
+ if theme_json.exists():
+ try:
+ data = json.loads(theme_json.read_text(encoding="utf-8"))
+ return ShadcnTheme.model_validate(data), str(theme_json)
+ 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:
+ from .theme_parser import parse_theme_source
+
+ return parse_theme_source(theme_xml), str(theme_xml)
+ except Exception as e:
+ logger.warning("Could not load theme.xml from %s: %s", cfg_dir, e)
+ return None, ""
+
+
+qsettings: QtShadcnSettings = QtShadcnSettings()
diff --git a/qtshadcn/common/helpers.py b/qtshadcn/common/helpers.py
index f01395a..42f8344 100644
--- a/qtshadcn/common/helpers.py
+++ b/qtshadcn/common/helpers.py
@@ -1,6 +1,7 @@
"""QtShadcn theme application helpers."""
import logging
+import os
from pathlib import Path
from typing import Any, cast
@@ -8,7 +9,6 @@
from ..exceptions import QtShadcnError
from ..models import ShadcnTheme, ShadcnThemeTokens
-from .theme_parser import resolve_value
logger = logging.getLogger(__name__)
@@ -82,17 +82,24 @@ def _as_token_overrides(value: dict[str, Any] | str | None) -> dict[str, str]:
def _override_tokens(tokens: ShadcnThemeTokens, overrides: dict[str, str]) -> ShadcnThemeTokens:
- """Return a new token set with resolved overrides applied."""
+ """Return a new token set with overrides applied."""
if not overrides:
return tokens
data = tokens.model_dump()
for key, raw_value in overrides.items():
if key not in data:
continue
- data[key] = resolve_value(raw_value)
+ data[key] = 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
+
+
+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)
diff --git a/qtshadcn/common/icon.py b/qtshadcn/common/icon.py
index c82db56..55d1d82 100644
--- a/qtshadcn/common/icon.py
+++ b/qtshadcn/common/icon.py
@@ -12,6 +12,8 @@
from qtpy import QtCore
+from ..exceptions import QtShadcnError
+
logger = logging.getLogger(__name__)
_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
@@ -77,7 +79,7 @@ def _write_icon(self, name: str, key: str, svg: str) -> str:
logger.debug("Wrote icon: %s", path)
except OSError as e:
logger.error("Failed to write icon %s: %s", path, e)
- raise
+ raise QtShadcnError(f"Failed to write icon {path}: {e}") from e
return str(path.resolve()).replace("\\", "/")
diff --git a/qtshadcn/common/renderer.py b/qtshadcn/common/renderer.py
index bf98395..53a6b2d 100644
--- a/qtshadcn/common/renderer.py
+++ b/qtshadcn/common/renderer.py
@@ -7,7 +7,7 @@
from ..exceptions import ThemeRenderError
from ..models import ShadcnThemeTokens
-from ..tokens import colors, radius, scale
+from ..tokens import colors, radius, scale, typography
from .helpers import _add_fonts
from .icon import ThemedIconManager
@@ -16,6 +16,42 @@
TEMPLATE_FILE = str(Path(__file__).resolve().parents[1] / "styles" / "shadcn.jinja")
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+def _render_template(source: str, context: dict) -> str:
+ """Render a Jinja template from a file path or inline string."""
+ try:
+ source_path = Path(source)
+ if source_path.exists():
+ env = jinja2.Environment(
+ autoescape=False,
+ loader=jinja2.FileSystemLoader(str(source_path.parent)),
+ )
+ tpl = env.get_template(source_path.name)
+ else:
+ env = jinja2.Environment(autoescape=False, loader=jinja2.BaseLoader())
+ tpl = env.from_string(source)
+
+ return tpl.render(**context)
+ except jinja2.TemplateSyntaxError as e:
+ raise ThemeRenderError(f"Failed to render template syntax: {e}") from e
+ except jinja2.TemplateError as e:
+ raise ThemeRenderError(f"Failed to render template: {e}") from e
+
+
+def _render_custom_snippet(custom: str, context: dict) -> str:
+ """Render an inline custom QSS Jinja snippet."""
+ return _render_template(custom, context)
+
+
+# ---------------------------------------------------------------------------
+# Public entry point
+# ---------------------------------------------------------------------------
+
+
def _build_theme(
tokens: ShadcnThemeTokens,
template: str = TEMPLATE_FILE,
@@ -23,12 +59,10 @@ def _build_theme(
is_dark: bool = False,
additional_qss: str | None = None,
) -> str:
- """Render the QSS stylesheet from resolved tokens.
+ """Render the base QSS stylesheet from resolved tokens.
- If ``additional_qss`` is provided it is treated as a Jinja template string
- and rendered with the same token context, then appended to the base
- stylesheet. It may also be a path to a ``.jinja`` or ``.qss`` file, in
- which case its content is loaded from disk before rendering.
+ ``additional_qss`` is an inline Jinja/QSS string appended to the base
+ stylesheet.
"""
try:
_add_fonts()
@@ -37,63 +71,20 @@ def _build_theme(
render_context = {
"tokens": tokens,
- "colors": colors,
+ "Colors": colors,
"icons": ThemedIconManager(),
"radius": radius,
- "scale": scale,
+ "Scale": scale,
+ "Typography": typography,
"is_dark": is_dark,
}
try:
- template_path = Path(template)
- if template_path.exists():
- parent = str(template_path.parent)
- template_name = template_path.name
- loader = jinja2.FileSystemLoader(parent)
- env = jinja2.Environment(autoescape=False, loader=loader)
- base_tpl = env.get_template(template_name)
- else:
- env = jinja2.Environment(autoescape=False, loader=jinja2.BaseLoader())
- base_tpl = env.from_string(template)
-
- stylesheet = base_tpl.render(**render_context)
- except jinja2.TemplateError as e:
+ stylesheet = _render_template(template, render_context)
+ except ThemeRenderError as e:
raise ThemeRenderError(f"Failed to render QSS theme template: {e}") from e
if additional_qss:
stylesheet += "\n" + _render_custom_snippet(additional_qss, render_context)
return stylesheet
-
-
-def _render_custom_snippet(custom: str, context: dict) -> str:
- """Render a custom QSS Jinja snippet and return the resulting string.
-
- ``custom`` may be:
-
- * A path to a ``.jinja`` or ``.qss`` file — its content is loaded from
- disk and rendered as a Jinja template.
- * A raw Jinja template string — rendered directly.
-
- Raises :class:`~qtshadcn.exceptions.ThemeRenderError` on any Jinja error.
- """
- custom_path = Path(custom)
- if custom_path.suffix.lower() in {".jinja", ".qss"} and custom_path.exists():
- logger.debug("Loading custom theme snippet from file: %s", custom_path)
- try:
- loader = jinja2.FileSystemLoader(str(custom_path.parent))
- env = jinja2.Environment(autoescape=False, loader=loader)
- tpl = env.get_template(custom_path.name)
- except jinja2.TemplateError as e:
- raise ThemeRenderError(f"Failed to load custom theme file '{custom_path}': {e}") from e
- else:
- env = jinja2.Environment(autoescape=False, loader=jinja2.BaseLoader())
- try:
- tpl = env.from_string(custom)
- except jinja2.TemplateError as e:
- raise ThemeRenderError(f"Failed to parse custom theme snippet: {e}") from e
-
- try:
- return tpl.render(**context)
- except jinja2.TemplateError as e:
- raise ThemeRenderError(f"Failed to render custom theme snippet: {e}") from e
diff --git a/qtshadcn/common/stylesheet.py b/qtshadcn/common/stylesheet.py
index b2e3352..be76e6e 100644
--- a/qtshadcn/common/stylesheet.py
+++ b/qtshadcn/common/stylesheet.py
@@ -17,15 +17,19 @@
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 .config import ThemeMode, _load_theme_from_dir, qsettings
+from .helpers import _apply_custom_tokens, _atomic_write, _looks_like_jinja, _resolve_theme_file
from .renderer import _build_theme
-from .theme_parser import parse_theme_source
+from .theme_parser import _parse_theme_source
logger = logging.getLogger(__name__)
_default_theme: str = "dark"
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
def _resolve_is_dark(mode: ThemeMode, default_theme: str = "dark") -> bool:
"""Resolve whether the active palette should be dark."""
@@ -39,34 +43,15 @@ def _resolve_is_dark(mode: ThemeMode, default_theme: str = "dark") -> bool:
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))
+ 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:
+def _render_and_apply() -> None:
"""Render the current theme and apply it to ``QApplication`` if it exists."""
app = cast(Any, QtWidgets.QApplication.instance())
if app is None:
@@ -74,8 +59,7 @@ def _render_and_apply(*, default_theme: str | None = None) -> None:
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)
+ is_dark = _resolve_is_dark(mode, _default_theme)
tokens = theme.dark if is_dark else theme.light
additional = qsettings.additional_style_sheet.value
@@ -87,14 +71,6 @@ def _render_and_apply(*, default_theme: str | None = None) -> None:
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):
@@ -107,50 +83,30 @@ def _normalize_theme_mode(mode: ThemeMode | str) -> ThemeMode:
) 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.
+def _write_theme_json(cfg_dir: Path, theme: ShadcnTheme) -> None:
+ """Persist the parsed theme as JSON in the config directory."""
+ data = theme.model_dump()
+ _atomic_write(cfg_dir / "theme.json", json.dumps(data, indent=2))
- 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.
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
- """
+
+def setThemeMode(mode: ThemeMode | str, *, save: bool = True) -> None:
+ """Set the active theme mode and re-render the stylesheet."""
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)
+ _render_and_apply()
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)
+ """Toggle between light and dark based on the resolved active palette."""
+ theme = ThemeMode.LIGHT if isDarkTheme() else ThemeMode.DARK
+ setThemeMode(theme, save=save)
def themeMode() -> ThemeMode:
@@ -163,57 +119,13 @@ def isDarkTheme() -> bool:
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/``.
-
- """
+ """Set the active color palette and re-render the stylesheet."""
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}")
@@ -238,7 +150,6 @@ def setTheme(
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)
@@ -247,35 +158,16 @@ def setTheme(
_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)
+ """Set the additional stylesheet layered on top of the base QSS."""
+ path = Path(source)
+ content = path.read_text(encoding="utf-8") if path.exists() else str(source)
qsettings.additional_style_sheet.set(content)
if save:
@@ -297,5 +189,8 @@ def getStyleSheet() -> str:
return qsettings.additional_style_sheet.value
+# Re-render when cached settings are loaded after QApplication startup.
+qsettings._on_load = lambda _settings: _render_and_apply()
+
# Keep QtCore.Qt alias import for type checkers that need Signal type reference.
_ = QtCore
diff --git a/qtshadcn/common/theme_parser.py b/qtshadcn/common/theme_parser.py
index 79a808f..8f89f91 100644
--- a/qtshadcn/common/theme_parser.py
+++ b/qtshadcn/common/theme_parser.py
@@ -1,14 +1,15 @@
"""QtShadcn native XML theme parser.
-The core runtime only accepts local QtShadcn XML theme files. tweakcn/shadcn
-JSON registries and raw CSS files are rejected at this boundary. Optional
-converter tooling that turns those formats into QtShadcn XML lives outside the
-application path.
+The core runtime accepts local QtShadcn XML theme files and QtShadcn JSON
+palette files. tweakcn/shadcn JSON registries and raw CSS files are rejected
+at this boundary. Theme values should be HEX colors or pixel sizes;
+``rgb()`` / ``rgba()`` values are passed through as QSS-compatible values.
+Optional converter tooling that turns external formats into QtShadcn XML lives
+outside the application path.
"""
+import json
import logging
-import math
-import re
from pathlib import Path
from xml.etree import ElementTree as ET
@@ -20,30 +21,43 @@
_REQUIRED_TOKENS = tuple(ShadcnThemeTokens.model_fields)
-# Match CSS functional values.
-_RE_OKLCH = re.compile(
- r"oklch\(\s*"
- r"(?P[-+]?\d*\.?\d+)%?\s+"
- r"(?P[-+]?\d*\.?\d+)\s+"
- r"(?P[-+]?\d*\.?\d+)(?:deg)?\s*"
- r"(?:/\s*(?P[-+]?\d*\.?\d+%?)\s*)?"
- r"\)",
- re.IGNORECASE,
-)
-_RE_HSL = re.compile(
- r"hsl\(\s*"
- r"(?P[-+]?\d*\.?\d+)(?:deg)?\s+"
- r"(?P[-+]?\d*\.?\d+)%\s+"
- r"(?P[-+]?\d*\.?\d+)%\s*"
- r"(?:/\s*(?P[-+]?\d*\.?\d+%?)\s*)?"
- r"\)",
- re.IGNORECASE,
-)
-_RE_REM = re.compile(r"^([\d.]+)rem$", re.IGNORECASE)
-_RE_RGB_RGBA = re.compile(
- r"^(rgba?)\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*"
- r"(?:,\s*([\d.]+)\s*)?\)$"
-)
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+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}")
+ 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}")
# ---------------------------------------------------------------------------
@@ -58,7 +72,8 @@ def parse_theme_source(path: Path | str) -> ShadcnTheme:
path: Local filesystem path to a ``.xml`` theme file.
Returns:
- A validated ``ShadcnTheme`` with resolved light and dark tokens.
+ A validated ``ShadcnTheme`` with light and dark tokens as written in
+ the XML file.
Raises:
ThemeParseError: For unsupported sources, malformed XML, or missing
@@ -66,7 +81,7 @@ def parse_theme_source(path: Path | str) -> ShadcnTheme:
"""
path_str = str(path)
- _validate_source(path_str)
+ _validate_xml_source(path_str)
path = Path(path)
logger.debug("Parsing XML theme: %s", path)
@@ -98,59 +113,12 @@ def parse_theme_source(path: Path | str) -> ShadcnTheme:
)
-def resolve_value(raw: str) -> str:
- """Resolve a single theme value to a Qt-safe string.
-
- Supported conversions:
-
- - ``oklch(L C H)`` -> ``rgb(r, g, b)``
- - ``oklch(L C H / alpha)`` -> ``rgba(r, g, b, alpha)``
- - ``hsl(H S% L%)`` -> ``rgb(r, g, b)``
- - ``hsl(H S% L% / alpha)`` -> ``rgba(r, g, b, alpha)``
- - ``rem`` -> ``px``
- - ``rgb(...)`` / ``rgba(...)`` -> passthrough
- - hex values -> passthrough
- - already resolved values (e.g. ``8px``) -> passthrough
-
- Args:
- raw: Token value as written in the XML theme.
-
- Returns:
- Qt-safe resolved value. Unresolvable inputs are returned unchanged so
- the caller can decide whether to fall back.
-
- """
- raw = raw.strip()
-
- if match := _RE_OKLCH.match(raw):
- return _oklch_to_rgb(
- match.group("L"),
- match.group("C"),
- match.group("H"),
- match.group("A"),
- )
-
- if match := _RE_HSL.match(raw):
- return _hsl_to_rgb(
- match.group("H"),
- match.group("S"),
- match.group("L"),
- match.group("A"),
- )
-
- if match := _RE_REM.match(raw):
- return _rem_to_px(match.group(1))
-
- # rgb()/rgba() and hex are already Qt-safe; pass them through.
- return raw
-
-
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
-def _validate_source(path_str: str) -> None:
+def _validate_xml_source(path_str: str) -> None:
"""Reject unsupported theme sources before touching the filesystem."""
text_path = path_str.lower()
@@ -208,107 +176,6 @@ def _resolve_palette(section, section_name: str) -> dict[str, str]:
resolved: dict[str, str] = {}
for name in _REQUIRED_TOKENS:
- raw = raw_tokens[name]
- try:
- value = resolve_value(raw)
- except Exception as e:
- logger.warning("Could not resolve token %r with value %r: %s", name, raw, e)
- value = raw
-
- resolved[name] = value
+ resolved[name] = raw_tokens[name]
return resolved
-
-
-def _rem_to_px(value: str) -> str:
- """Convert a rem value to px using 1rem = 16px."""
- px = float(value) * 16
- if px % 1 == 0:
- return f"{int(px)}px"
- return f"{px}px"
-
-
-def _oklch_to_rgb(l_str: str, c_str: str, h_str: str, alpha_str: str | None) -> str:
- """Convert OKLCH to sRGB, returning rgb() or rgba()."""
- l_val = float(l_str) / 100.0 if l_str.endswith("%") else float(l_str)
- c_val = float(c_str)
- h_deg = float(h_str)
- if alpha_str is None:
- alpha = None
- elif alpha_str.endswith("%"):
- alpha = float(alpha_str[:-1]) / 100.0
- else:
- alpha = float(alpha_str)
-
- # OKLCH -> OKLab
- h_rad = math.radians(h_deg)
- a = c_val * math.cos(h_rad)
- b = c_val * math.sin(h_rad)
-
- # OKLab -> linear LMS
- lms_l = l_val + 0.3963377774 * a + 0.2158037573 * b
- lms_m = l_val - 0.1055613458 * a - 0.0638541728 * b
- lms_s = l_val - 0.0894841775 * a - 1.2914855480 * b
-
- # Non-linear LMS
- lms_l = lms_l**3
- lms_m = lms_m**3
- lms_s = lms_s**3
-
- # LMS (non-linear) -> linear sRGB. Matrix is the product of the standard
- # XYZ->linear-sRGB matrix and the inverse of the OKLab LMS->XYZ matrix.
- r_lin = 4.07718682 * lms_l - 3.30762252 * lms_m + 0.23085920 * lms_s
- g_lin = -1.26857649 * lms_l + 2.60968711 * lms_m - 0.34115575 * lms_s
- b_lin = -0.00419654 * lms_l - 0.70339968 * lms_m + 1.70679603 * lms_s
-
- r = _gamma_correct(r_lin)
- g = _gamma_correct(g_lin)
- b = _gamma_correct(b_lin)
-
- if alpha is not None:
- return f"rgba({r}, {g}, {b}, {alpha})"
- return f"rgb({r}, {g}, {b})"
-
-
-def _hsl_to_rgb(h_str: str, s_str: str, l_str: str, alpha_str: str | None) -> str:
- """Convert HSL to sRGB, returning rgb() or rgba()."""
- h = float(h_str) % 360.0
- s = float(s_str) / 100.0
- lightness = float(l_str) / 100.0
- if alpha_str is None:
- alpha = None
- elif alpha_str.endswith("%"):
- alpha = float(alpha_str[:-1]) / 100.0
- else:
- alpha = float(alpha_str)
-
- c = (1.0 - abs(2.0 * lightness - 1.0)) * s
- x = c * (1.0 - abs((h / 60.0) % 2.0 - 1.0))
- m = lightness - c / 2.0
-
- if 0 <= h < 60:
- r1, g1, b1 = c, x, 0.0
- elif 60 <= h < 120:
- r1, g1, b1 = x, c, 0.0
- elif 120 <= h < 180:
- r1, g1, b1 = 0.0, c, x
- elif 180 <= h < 240:
- r1, g1, b1 = 0.0, x, c
- elif 240 <= h < 300:
- r1, g1, b1 = x, 0.0, c
- else:
- r1, g1, b1 = c, 0.0, x
-
- r = int(round((r1 + m) * 255))
- g = int(round((g1 + m) * 255))
- b = int(round((b1 + m) * 255))
-
- if alpha is not None:
- return f"rgba({r}, {g}, {b}, {alpha})"
- return f"rgb({r}, {g}, {b})"
-
-
-def _gamma_correct(channel: float) -> int:
- """Apply sRGB gamma correction and clamp to 0-255."""
- linear = channel * 12.92 if channel <= 0.0031308 else 1.055 * channel ** (1.0 / 2.4) - 0.055
- return max(0, min(255, int(round(linear * 255))))
diff --git a/qtshadcn/common/theme_watcher.py b/qtshadcn/common/theme_watcher.py
index cd2d6fd..096e2cb 100644
--- a/qtshadcn/common/theme_watcher.py
+++ b/qtshadcn/common/theme_watcher.py
@@ -1,31 +1,27 @@
"""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.
+The listener runs in its own ``QThread`` and polls ``darkdetect.theme()``
+to detect light/dark switches. After emitting a change it sleeps for two
+seconds to avoid repeated signals from short-lived transitions.
"""
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
+# qtpy exposes QtCore.QThread as a binding union at type-check time, so we
+# alias it to ``Any`` to keep ``ty`` happy while remaining binding-neutral.
+_BaseThread: Any = QtCore.QThread
+logger = logging.getLogger(__name__)
-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()``.
- """
+class SystemThemeListener(_BaseThread):
+ """Emit ``themeChanged("Dark"|"Light")`` when the OS theme changes."""
themeChanged = QtCore.Signal(str)
@@ -35,47 +31,26 @@ def __init__(
*,
poll_interval_ms: int = 1000,
) -> None:
- """Create a listener with the requested polling fallback interval."""
+ """Set up the polling thread."""
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:
+ """Poll the OS theme and emit ``themeChanged`` on transitions."""
last_theme = darkdetect.theme()
- while self._running:
+ while not self.isInterruptionRequested():
current = darkdetect.theme()
if current != last_theme and current in {"Dark", "Light"}:
- self._on_theme_changed(current)
+ logger.debug("OS theme changed to %s", current)
+ self.themeChanged.emit(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)
+ # Polling interval is ignored here on purpose: a longer sleep
+ # after a change suppresses rapid repeated OS transitions.
+ self.msleep(2000)
+ else:
+ self.msleep(self._poll_interval_ms)
def stop(self) -> None:
- """Request the listener thread to exit and wait for it to finish."""
- self._running = False
+ """Request a clean thread shutdown."""
+ self.requestInterruption()
self.wait(5000)
-
-
-_ = Any
diff --git a/qtshadcn/fonts/Inter/Inter-Black.ttf b/qtshadcn/fonts/Inter/Inter-Black.ttf
new file mode 100644
index 0000000..1bea7c3
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-Black.ttf differ
diff --git a/qtshadcn/fonts/Inter/Inter-Bold.ttf b/qtshadcn/fonts/Inter/Inter-Bold.ttf
new file mode 100644
index 0000000..ae2c2d4
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-Bold.ttf differ
diff --git a/qtshadcn/fonts/Inter/Inter-ExtraBold.ttf b/qtshadcn/fonts/Inter/Inter-ExtraBold.ttf
new file mode 100644
index 0000000..cc8b55c
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-ExtraBold.ttf differ
diff --git a/qtshadcn/fonts/Inter/Inter-ExtraLight.ttf b/qtshadcn/fonts/Inter/Inter-ExtraLight.ttf
new file mode 100644
index 0000000..d7131bd
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-ExtraLight.ttf differ
diff --git a/qtshadcn/fonts/Inter/Inter-Light.ttf b/qtshadcn/fonts/Inter/Inter-Light.ttf
new file mode 100644
index 0000000..fe099bd
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-Light.ttf differ
diff --git a/qtshadcn/fonts/Inter/Inter-Medium.ttf b/qtshadcn/fonts/Inter/Inter-Medium.ttf
new file mode 100644
index 0000000..e3e4daa
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-Medium.ttf differ
diff --git a/qtshadcn/fonts/Inter/Inter-Regular.ttf b/qtshadcn/fonts/Inter/Inter-Regular.ttf
new file mode 100644
index 0000000..9a53a64
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-Regular.ttf differ
diff --git a/qtshadcn/fonts/Inter/Inter-SemiBold.ttf b/qtshadcn/fonts/Inter/Inter-SemiBold.ttf
new file mode 100644
index 0000000..61f1cc6
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-SemiBold.ttf differ
diff --git a/qtshadcn/fonts/Inter/Inter-Thin.ttf b/qtshadcn/fonts/Inter/Inter-Thin.ttf
new file mode 100644
index 0000000..5dfaa42
Binary files /dev/null and b/qtshadcn/fonts/Inter/Inter-Thin.ttf differ
diff --git a/qtshadcn/fonts/opensans/OpenSans-BoldItalic.ttf b/qtshadcn/fonts/opensans/OpenSans-BoldItalic.ttf
deleted file mode 100644
index e246ffa..0000000
Binary files a/qtshadcn/fonts/opensans/OpenSans-BoldItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/opensans/OpenSans-ExtraBoldItalic.ttf b/qtshadcn/fonts/opensans/OpenSans-ExtraBoldItalic.ttf
deleted file mode 100644
index 02288dc..0000000
Binary files a/qtshadcn/fonts/opensans/OpenSans-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/opensans/OpenSans-Italic.ttf b/qtshadcn/fonts/opensans/OpenSans-Italic.ttf
deleted file mode 100644
index 569e6b0..0000000
Binary files a/qtshadcn/fonts/opensans/OpenSans-Italic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/opensans/OpenSans-LightItalic.ttf b/qtshadcn/fonts/opensans/OpenSans-LightItalic.ttf
deleted file mode 100644
index 6d6d917..0000000
Binary files a/qtshadcn/fonts/opensans/OpenSans-LightItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/opensans/OpenSans-MediumItalic.ttf b/qtshadcn/fonts/opensans/OpenSans-MediumItalic.ttf
deleted file mode 100644
index c238154..0000000
Binary files a/qtshadcn/fonts/opensans/OpenSans-MediumItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/opensans/OpenSans-SemiBoldItalic.ttf b/qtshadcn/fonts/opensans/OpenSans-SemiBoldItalic.ttf
deleted file mode 100644
index 7a7d9cd..0000000
Binary files a/qtshadcn/fonts/opensans/OpenSans-SemiBoldItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/Roboto-Black.ttf b/qtshadcn/fonts/roboto/Roboto-Black.ttf
index 689fe5c..f939fc7 100644
Binary files a/qtshadcn/fonts/roboto/Roboto-Black.ttf and b/qtshadcn/fonts/roboto/Roboto-Black.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-BlackItalic.ttf b/qtshadcn/fonts/roboto/Roboto-BlackItalic.ttf
deleted file mode 100644
index 0b4e0ee..0000000
Binary files a/qtshadcn/fonts/roboto/Roboto-BlackItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/Roboto-Bold.ttf b/qtshadcn/fonts/roboto/Roboto-Bold.ttf
index d3f01ad..6516185 100644
Binary files a/qtshadcn/fonts/roboto/Roboto-Bold.ttf and b/qtshadcn/fonts/roboto/Roboto-Bold.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-BoldItalic.ttf b/qtshadcn/fonts/roboto/Roboto-BoldItalic.ttf
deleted file mode 100644
index 41cc1e7..0000000
Binary files a/qtshadcn/fonts/roboto/Roboto-BoldItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/Roboto-ExtraBold.ttf b/qtshadcn/fonts/roboto/Roboto-ExtraBold.ttf
new file mode 100644
index 0000000..9dc48a1
Binary files /dev/null and b/qtshadcn/fonts/roboto/Roboto-ExtraBold.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-ExtraLight.ttf b/qtshadcn/fonts/roboto/Roboto-ExtraLight.ttf
new file mode 100644
index 0000000..5e517b3
Binary files /dev/null and b/qtshadcn/fonts/roboto/Roboto-ExtraLight.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-Italic.ttf b/qtshadcn/fonts/roboto/Roboto-Italic.ttf
deleted file mode 100644
index 6a1cee5..0000000
Binary files a/qtshadcn/fonts/roboto/Roboto-Italic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/Roboto-Light.ttf b/qtshadcn/fonts/roboto/Roboto-Light.ttf
index 219063a..ee7268f 100644
Binary files a/qtshadcn/fonts/roboto/Roboto-Light.ttf and b/qtshadcn/fonts/roboto/Roboto-Light.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-LightItalic.ttf b/qtshadcn/fonts/roboto/Roboto-LightItalic.ttf
deleted file mode 100644
index 0e81e87..0000000
Binary files a/qtshadcn/fonts/roboto/Roboto-LightItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/Roboto-Medium.ttf b/qtshadcn/fonts/roboto/Roboto-Medium.ttf
index 1a7f3b0..bc5b170 100644
Binary files a/qtshadcn/fonts/roboto/Roboto-Medium.ttf and b/qtshadcn/fonts/roboto/Roboto-Medium.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-MediumItalic.ttf b/qtshadcn/fonts/roboto/Roboto-MediumItalic.ttf
deleted file mode 100644
index 0030295..0000000
Binary files a/qtshadcn/fonts/roboto/Roboto-MediumItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/Roboto-Regular.ttf b/qtshadcn/fonts/roboto/Roboto-Regular.ttf
index 2c97eea..3db0d1f 100644
Binary files a/qtshadcn/fonts/roboto/Roboto-Regular.ttf and b/qtshadcn/fonts/roboto/Roboto-Regular.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-SemiBold.ttf b/qtshadcn/fonts/roboto/Roboto-SemiBold.ttf
new file mode 100644
index 0000000..7a8ef87
Binary files /dev/null and b/qtshadcn/fonts/roboto/Roboto-SemiBold.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-Thin.ttf b/qtshadcn/fonts/roboto/Roboto-Thin.ttf
index b74a4fd..cdd56c5 100644
Binary files a/qtshadcn/fonts/roboto/Roboto-Thin.ttf and b/qtshadcn/fonts/roboto/Roboto-Thin.ttf differ
diff --git a/qtshadcn/fonts/roboto/Roboto-ThinItalic.ttf b/qtshadcn/fonts/roboto/Roboto-ThinItalic.ttf
deleted file mode 100644
index dd0ddb8..0000000
Binary files a/qtshadcn/fonts/roboto/Roboto-ThinItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/RobotoCondensed-Bold.ttf b/qtshadcn/fonts/roboto/RobotoCondensed-Bold.ttf
deleted file mode 100644
index 48dd635..0000000
Binary files a/qtshadcn/fonts/roboto/RobotoCondensed-Bold.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/RobotoCondensed-BoldItalic.ttf b/qtshadcn/fonts/roboto/RobotoCondensed-BoldItalic.ttf
deleted file mode 100644
index ad72864..0000000
Binary files a/qtshadcn/fonts/roboto/RobotoCondensed-BoldItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/RobotoCondensed-Italic.ttf b/qtshadcn/fonts/roboto/RobotoCondensed-Italic.ttf
deleted file mode 100644
index a232513..0000000
Binary files a/qtshadcn/fonts/roboto/RobotoCondensed-Italic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/RobotoCondensed-Light.ttf b/qtshadcn/fonts/roboto/RobotoCondensed-Light.ttf
deleted file mode 100644
index a6e368d..0000000
Binary files a/qtshadcn/fonts/roboto/RobotoCondensed-Light.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/RobotoCondensed-LightItalic.ttf b/qtshadcn/fonts/roboto/RobotoCondensed-LightItalic.ttf
deleted file mode 100644
index 5b2b6ae..0000000
Binary files a/qtshadcn/fonts/roboto/RobotoCondensed-LightItalic.ttf and /dev/null differ
diff --git a/qtshadcn/fonts/roboto/RobotoCondensed-Regular.ttf b/qtshadcn/fonts/roboto/RobotoCondensed-Regular.ttf
deleted file mode 100644
index 65bf32a..0000000
Binary files a/qtshadcn/fonts/roboto/RobotoCondensed-Regular.ttf and /dev/null differ
diff --git a/qtshadcn/styles/shadcn.jinja b/qtshadcn/styles/shadcn.jinja
index f2433ee..b2915fc 100644
--- a/qtshadcn/styles/shadcn.jinja
+++ b/qtshadcn/styles/shadcn.jinja
@@ -2,8 +2,8 @@
* {
color: {{ tokens.foreground }};
- font-family: {{ tokens.font_family }};
- font-size: {{ scale.TEXT_BASE }};
+ font-size: {{ Typography.FontSize.BASE }};
+ font-family: "{{ tokens.font_family }}", sans-serif;
}
/* ============================================================
@@ -13,7 +13,7 @@
QWidget {
color: {{ tokens.foreground }};
background-color: {{ tokens.card }};
- border-radius: {{ radius.class_px(tokens.radius, "xl") }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "xl") }};
}
QTabWidget > QWidget {
@@ -28,7 +28,7 @@ QFrame {
color: {{ tokens.foreground }};
background-color: {{ tokens.card }};
border: 1px solid {{ tokens.border }};
- border-radius: {{ radius.class_px(tokens.radius, "xl") }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "xl") }};
}
@@ -38,8 +38,8 @@ QFrame {
QLabel {
line-height: 100%;
- font-size: {{ scale.TEXT_SM }};
- font-weight: {{ scale.FONT_WEIGHT_MEDIUM }};
+ font-size: {{ Typography.FontSize.SM }};
+ font-weight: {{ Typography.FontWeight.MEDIUM }};
color: {{ tokens.foreground }};
border: none;
}
@@ -48,7 +48,7 @@ QLabel[invalid="true"] {
color: {{ tokens.destructive }};
}
-QLabel:disabled { color: {{ colors.alpha(tokens.foreground, 0.5) }}; }
+QLabel:disabled { color: {{ Colors.withAlpha(tokens.foreground, 0.5) }}; }
/* ============================================================
@@ -57,31 +57,30 @@ QLabel:disabled { color: {{ colors.alpha(tokens.foreground, 0.5) }}; }
QGroupBox {
border: 1px solid {{ tokens.border }};
- border-radius: {{ radius.class_px(tokens.radius, "md") }};
- padding: {{ scale.spacing_px(tokens.spacing, 3) }};
- padding-top: {{ scale.spacing_px(tokens.spacing, 4) }};
- margin-top: {{ scale.spacing_px(tokens.spacing, 3) }};
- font-size: {{ scale.TEXT_SM }};
- line-height: {{ scale.TEXT_SM }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "md") }};
+ padding: {{ Scale.resolveSpacing(tokens.spacing, 3) }};
+ padding-top: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ margin-top: {{ Scale.resolveSpacing(tokens.spacing, 3) }};
+ font-size: {{ Typography.FontSize.SM }};
text-transform: uppercase;
}
QGroupBox::title {
top: 0;
subcontrol-origin: margin;
- left: {{ scale.spacing_px(tokens.spacing, 3) }};
- padding: 0 {{ scale.spacing_px(tokens.spacing, 1) }};
+ left: {{ Scale.resolveSpacing(tokens.spacing, 3) }};
+ padding: 0 {{ Scale.resolveSpacing(tokens.spacing, 1) }};
}
QGroupBox::title:disabled {
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
QGroupBox:flat {
border: none;
border-radius: 0;
- margin-top: {{ scale.spacing_px(tokens.spacing, 3) }};
- padding-top: {{ scale.spacing_px(tokens.spacing, 4) }};
+ margin-top: {{ Scale.resolveSpacing(tokens.spacing, 3) }};
+ padding-top: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
}
QGroupBox:flat::title {
@@ -89,22 +88,22 @@ QGroupBox:flat::title {
left: 0;
background: transparent;
subcontrol-origin: margin;
- padding: 0 {{ scale.spacing_px(tokens.spacing, 1) }} 0 0;
+ padding: 0 {{ Scale.resolveSpacing(tokens.spacing, 1) }} 0 0;
}
QGroupBox:flat::title:disabled {
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
QGroupBox::indicator {
- width: {{ scale.spacing_px(tokens.spacing, 4) }};
- height: {{ scale.spacing_px(tokens.spacing, 4) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ height: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
border: 1px solid {{ tokens.input }};
- border-radius: {{ radius.class_px(tokens.radius, "sm") }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "sm") }};
background: {{ checkbox_bg }};
}
-QGroupBox::indicator:hover { border-color: {{ colors.alpha(tokens.ring, 0.5) }}; }
+QGroupBox::indicator:hover { border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }}; }
QGroupBox::indicator:checked,
QGroupBox::indicator:indeterminate {
@@ -121,14 +120,14 @@ QGroupBox::indicator:indeterminate {
}
QGroupBox::indicator:disabled {
- background: {{ colors.alpha(tokens.muted, 0.5) }};
- border-color: {{ colors.alpha(tokens.input, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.muted, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.input, 0.5) }};
}
QGroupBox::indicator:checked:disabled,
QGroupBox::indicator:indeterminate:disabled {
- background: {{ colors.alpha(tokens.primary, 0.5) }};
- border-color: {{ colors.alpha(tokens.primary, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.primary, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.primary, 0.5) }};
}
@@ -136,64 +135,64 @@ QGroupBox::indicator:indeterminate:disabled {
QPushButton
============================================================ */
-{% set h_default = scale.spacing_px(tokens.spacing, 8) %}
-{% set h_sm = scale.spacing_px(tokens.spacing, 7) %}
-{% set h_lg = scale.spacing_px(tokens.spacing, 9) %}
-{% set h_xs = scale.spacing_px(tokens.spacing, 6) %}
+{% set h_default = Scale.resolveSpacing(tokens.spacing, 8) %}
+{% set h_sm = Scale.resolveSpacing(tokens.spacing, 7) %}
+{% set h_lg = Scale.resolveSpacing(tokens.spacing, 9) %}
+{% set h_xs = Scale.resolveSpacing(tokens.spacing, 6) %}
QPushButton {
background-color: {{ tokens.primary }};
color: {{ tokens.primary_foreground }};
border: 1px solid transparent;
outline: none;
- border-radius: {{ radius.class_px(tokens.radius, "lg") }};
- padding-left: {{ scale.spacing_px(tokens.spacing, 2.5) }};
- padding-right: {{ scale.spacing_px(tokens.spacing, 2.5) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 8) }};
- font-size: {{ scale.TEXT_SM }};
- font-weight: {{ scale.FONT_WEIGHT_MEDIUM }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
+ padding-left: {{ Scale.resolveSpacing(tokens.spacing, 2.5) }};
+ padding-right: {{ Scale.resolveSpacing(tokens.spacing, 2.5) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
+ font-size: {{ Typography.FontSize.SM }};
+ font-weight: {{ Typography.FontWeight.MEDIUM }};
}
QToolButton[class~="rounded-full"],
QPushButton[class~="rounded-full"] {
- border-radius: {{ radius.half_px(h_default) }};
+ border-radius: {{ radius.halveRadius(h_default) }};
}
QToolButton[class~="rounded-full"][scale="sm"],
QPushButton[class~="rounded-full"][scale="sm"] {
- border-radius: {{ radius.half_px(h_sm) }};
+ border-radius: {{ radius.halveRadius(h_sm) }};
}
QToolButton[class~="rounded-full"][scale="lg"],
QPushButton[class~="rounded-full"][scale="lg"] {
- border-radius: {{ radius.half_px(h_lg) }};
+ border-radius: {{ radius.halveRadius(h_lg) }};
}
QToolButton[class~="rounded-full"][scale="xs"],
QPushButton[class~="rounded-full"][scale="xs"] {
- border-radius: {{ radius.half_px(h_xs) }};
+ border-radius: {{ radius.halveRadius(h_xs) }};
}
QToolButton:hover,
QPushButton:hover {
- background-color: {{ colors.alpha(tokens.primary, 0.8) }};
+ background-color: {{ Colors.withAlpha(tokens.primary, 0.8) }};
}
QPushButton:pressed {
color: {{ tokens.secondary_foreground }};
- background-color: {{ colors.alpha(tokens.primary, 0.8) }};
+ background-color: {{ Colors.withAlpha(tokens.primary, 0.8) }};
}
QPushButton:focus {
background-color: {{ tokens.primary }};
color: {{ tokens.primary_foreground }};
- border-color: {{ colors.alpha(tokens.ring, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }};
outline: none;
}
QPushButton:disabled {
- background-color: {{ colors.alpha(tokens.primary, 0.5) }};
- color: {{ colors.alpha(tokens.primary_foreground, 0.5) }};
+ background-color: {{ Colors.withAlpha(tokens.primary, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.primary_foreground, 0.5) }};
border-color: transparent;
}
@@ -222,7 +221,7 @@ QPushButton[variant="secondary"] {
QPushButton[variant="secondary"]:hover,
QPushButton[variant="secondary"]:pressed {
- background-color: {{ colors.mix(tokens.secondary, tokens.foreground, 0.05) }};
+ background-color: {{ Colors.blendColors(tokens.secondary, tokens.foreground, 0.05) }};
color: {{ tokens.secondary_foreground }};
}
@@ -239,14 +238,14 @@ QPushButton[variant="ghost"]:pressed {
}
QPushButton[variant="destructive"] {
- background-color: {{ colors.alpha(tokens.destructive, 0.1) }};
+ background-color: {{ Colors.withAlpha(tokens.destructive, 0.1) }};
color: {{ tokens.destructive }};
border-color: transparent;
}
QPushButton[variant="destructive"]:hover,
QPushButton[variant="destructive"]:pressed {
- background-color: {{ colors.alpha(tokens.destructive, 0.2) }};
+ background-color: {{ Colors.withAlpha(tokens.destructive, 0.2) }};
}
QPushButton[variant="link"] {
@@ -267,7 +266,7 @@ QPushButton[variant="link"]:pressed {
QPushButton[variant="outline"]:disabled {
background-color: transparent;
- color: {{ colors.alpha(tokens.foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.foreground, 0.5) }};
border-color: {{ tokens.border }};
}
@@ -275,26 +274,26 @@ QPushButton[variant="ghost"]:disabled,
QPushButton[variant="link"]:disabled {
border: 0px;
background-color: transparent;
- color: {{ colors.alpha(tokens.foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.foreground, 0.5) }};
}
QPushButton[scale="xs"] {
- border-radius: {{ radius.min_px(tokens.radius, "md", "10px") }};
- padding-left: {{ scale.spacing_px(tokens.spacing, 2) }};
- padding-right: {{ scale.spacing_px(tokens.spacing, 2) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 6) }};
- font-size: {{ scale.TEXT_XS }};
+ border-radius: {{ radius.clampRadius(tokens.radius, "md", "10px") }};
+ padding-left: {{ Scale.resolveSpacing(tokens.spacing, 2) }};
+ padding-right: {{ Scale.resolveSpacing(tokens.spacing, 2) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 6) }};
+ font-size: {{ Typography.FontSize.XS }};
}
QPushButton[scale="sm"] {
- border-radius: {{ radius.min_px(tokens.radius, "md", "12px") }};
- min-height: {{ scale.spacing_px(tokens.spacing, 7) }};
- font-size: 13px;
+ border-radius: {{ radius.clampRadius(tokens.radius, "md", "12px") }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 7) }};
+ font-size: {{ Typography.FontSize.SM }};
}
QPushButton[scale="lg"] {
- min-height: {{ scale.spacing_px(tokens.spacing, 9) }};
- font-size: {{ scale.TEXT_SM }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 9) }};
+ font-size: {{ Typography.FontSize.SM }};
}
/* ============================================================
@@ -306,34 +305,34 @@ QToolButton {
color: {{ tokens.primary_foreground }};
border: 1px solid transparent;
outline: none;
- border-radius: {{ radius.class_px(tokens.radius, "lg") }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
padding: 0px;
- min-width: {{ scale.spacing_px(tokens.spacing, 8) }};
- max-width: {{ scale.spacing_px(tokens.spacing, 8) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 8) }};
- max-height: {{ scale.spacing_px(tokens.spacing, 8) }};
- font-size: {{ scale.TEXT_SM }};
- font-weight: {{ scale.FONT_WEIGHT_MEDIUM }};
+ min-width: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
+ max-width: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
+ max-height: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
+ font-size: {{ Typography.FontSize.SM }};
+ font-weight: {{ Typography.FontWeight.MEDIUM }};
}
-QToolButton:hover { background-color: {{ colors.alpha(tokens.primary, 0.8) }}; }
+QToolButton:hover { background-color: {{ Colors.withAlpha(tokens.primary, 0.8) }}; }
QToolButton:pressed,
QToolButton:checked {
- background-color: {{ colors.alpha(tokens.primary, 0.8) }};
+ background-color: {{ Colors.withAlpha(tokens.primary, 0.8) }};
color: {{ tokens.primary_foreground }};
}
QToolButton:focus {
background-color: {{ tokens.primary }};
color: {{ tokens.primary_foreground }};
- border-color: {{ colors.alpha(tokens.ring, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }};
outline: none;
}
QToolButton:disabled {
- background-color: {{ colors.alpha(tokens.primary, 0.5) }};
- color: {{ colors.alpha(tokens.primary_foreground, 0.5) }};
+ background-color: {{ Colors.withAlpha(tokens.primary, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.primary_foreground, 0.5) }};
border-color: transparent;
}
@@ -346,7 +345,7 @@ QToolButton[variant="outline"] {
QToolButton[variant="outline"]:focus {
background-color: {{ tokens.background }};
color: {{ tokens.foreground }};
- border-color: {{ colors.alpha(tokens.ring, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }};
}
QToolButton[variant="outline"]:hover,
@@ -365,13 +364,13 @@ QToolButton[variant="secondary"] {
QToolButton[variant="secondary"]:focus {
background-color: {{ tokens.secondary }};
color: {{ tokens.secondary_foreground }};
- border-color: {{ colors.alpha(tokens.ring, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }};
}
QToolButton[variant="secondary"]:hover,
QToolButton[variant="secondary"]:pressed,
QToolButton[variant="secondary"]:checked {
- background-color: {{ colors.mix(tokens.secondary, tokens.foreground, 0.05) }};
+ background-color: {{ Colors.blendColors(tokens.secondary, tokens.foreground, 0.05) }};
color: {{ tokens.secondary_foreground }};
}
@@ -384,7 +383,7 @@ QToolButton[variant="ghost"] {
QToolButton[variant="ghost"]:focus {
background-color: transparent;
color: {{ tokens.foreground }};
- border-color: {{ colors.alpha(tokens.ring, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }};
}
QToolButton[variant="ghost"]:hover,
@@ -395,69 +394,69 @@ QToolButton[variant="ghost"]:checked {
}
QToolButton[variant="destructive"] {
- background-color: {{ colors.alpha(tokens.destructive, 0.1) }};
+ background-color: {{ Colors.withAlpha(tokens.destructive, 0.1) }};
color: {{ tokens.destructive }};
border-color: transparent;
}
QToolButton[variant="destructive"]:focus {
- background-color: {{ colors.alpha(tokens.destructive, 0.1) }};
+ background-color: {{ Colors.withAlpha(tokens.destructive, 0.1) }};
color: {{ tokens.destructive }};
- border-color: {{ colors.alpha(tokens.ring, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }};
}
QToolButton[variant="destructive"]:hover,
QToolButton[variant="destructive"]:pressed,
QToolButton[variant="destructive"]:checked {
- background-color: {{ colors.alpha(tokens.destructive, 0.2) }};
+ background-color: {{ Colors.withAlpha(tokens.destructive, 0.2) }};
color: {{ tokens.destructive }};
}
QToolButton[variant="outline"]:disabled {
background-color: transparent;
- color: {{ colors.alpha(tokens.foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.foreground, 0.5) }};
border-color: {{ tokens.border }};
}
QToolButton[variant="secondary"]:disabled {
- background-color: {{ colors.alpha(tokens.secondary, 0.5) }};
- color: {{ colors.alpha(tokens.secondary_foreground, 0.5) }};
+ background-color: {{ Colors.withAlpha(tokens.secondary, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.secondary_foreground, 0.5) }};
border-color: transparent;
}
QToolButton[variant="ghost"]:disabled {
border: 0px;
background-color: transparent;
- color: {{ colors.alpha(tokens.foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.foreground, 0.5) }};
}
QToolButton[variant="destructive"]:disabled {
- background-color: {{ colors.alpha(tokens.destructive, 0.1) }};
- color: {{ colors.alpha(tokens.destructive, 0.5) }};
+ background-color: {{ Colors.withAlpha(tokens.destructive, 0.1) }};
+ color: {{ Colors.withAlpha(tokens.destructive, 0.5) }};
border-color: transparent;
}
QToolButton[scale="xs"] {
- border-radius: {{ radius.min_px(tokens.radius, "md", "10px") }};
- min-width: {{ scale.spacing_px(tokens.spacing, 6) }};
- max-width: {{ scale.spacing_px(tokens.spacing, 6) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 6) }};
- max-height: {{ scale.spacing_px(tokens.spacing, 6) }};
+ border-radius: {{ radius.clampRadius(tokens.radius, "md", "10px") }};
+ min-width: {{ Scale.resolveSpacing(tokens.spacing, 6) }};
+ max-width: {{ Scale.resolveSpacing(tokens.spacing, 6) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 6) }};
+ max-height: {{ Scale.resolveSpacing(tokens.spacing, 6) }};
}
QToolButton[scale="sm"] {
- border-radius: {{ radius.min_px(tokens.radius, "md", "12px") }};
- min-width: {{ scale.spacing_px(tokens.spacing, 7) }};
- max-width: {{ scale.spacing_px(tokens.spacing, 7) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 7) }};
- max-height: {{ scale.spacing_px(tokens.spacing, 7) }};
+ border-radius: {{ radius.clampRadius(tokens.radius, "md", "12px") }};
+ min-width: {{ Scale.resolveSpacing(tokens.spacing, 7) }};
+ max-width: {{ Scale.resolveSpacing(tokens.spacing, 7) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 7) }};
+ max-height: {{ Scale.resolveSpacing(tokens.spacing, 7) }};
}
QToolButton[scale="lg"] {
- min-width: {{ scale.spacing_px(tokens.spacing, 9) }};
- max-width: {{ scale.spacing_px(tokens.spacing, 9) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 9) }};
- max-height: {{ scale.spacing_px(tokens.spacing, 9) }};
+ min-width: {{ Scale.resolveSpacing(tokens.spacing, 9) }};
+ max-width: {{ Scale.resolveSpacing(tokens.spacing, 9) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 9) }};
+ max-height: {{ Scale.resolveSpacing(tokens.spacing, 9) }};
}
@@ -465,27 +464,27 @@ QToolButton[scale="lg"] {
QCheckBox
============================================================ */
-{% set checkbox_bg = colors.alpha(tokens.input, 0.3) if is_dark else "transparent" %}
-{% set invalid_border = colors.alpha(tokens.destructive, 0.5) if is_dark else tokens.destructive %}
-{% set invalid_outline = colors.alpha(tokens.destructive, 0.4 if is_dark else 0.2) %}
+{% set checkbox_bg = Colors.withAlpha(tokens.input, 0.3) if is_dark else "transparent" %}
+{% set invalid_border = Colors.withAlpha(tokens.destructive, 0.5) if is_dark else tokens.destructive %}
+{% set invalid_outline = Colors.withAlpha(tokens.destructive, 0.4 if is_dark else 0.2) %}
QCheckBox {
background: transparent;
color: {{ tokens.foreground }};
- spacing: {{ scale.spacing_px(tokens.spacing, 2) }};
- font-size: {{ scale.TEXT_SM }};
- font-weight: {{ scale.FONT_WEIGHT_MEDIUM }};
+ spacing: {{ Scale.resolveSpacing(tokens.spacing, 2) }};
+ font-size: {{ Typography.FontSize.SM }};
+ font-weight: {{ Typography.FontWeight.MEDIUM }};
}
QCheckBox::indicator {
- width: {{ scale.spacing_px(tokens.spacing, 4) }};
- height: {{ scale.spacing_px(tokens.spacing, 4) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ height: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
border: 1px solid {{ tokens.input }};
- border-radius: {{ radius.class_px(tokens.radius, "sm") }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "sm") }};
background: {{ checkbox_bg }};
}
-QCheckBox::indicator:hover { border-color: {{ colors.alpha(tokens.ring, 0.5) }}; }
+QCheckBox::indicator:hover { border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }}; }
QCheckBox::indicator:checked,
QCheckBox::indicator:indeterminate {
@@ -502,18 +501,18 @@ QCheckBox::indicator:indeterminate {
}
QCheckBox::indicator:disabled {
- background: {{ colors.alpha(tokens.muted, 0.5) }};
- border-color: {{ colors.alpha(tokens.input, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.muted, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.input, 0.5) }};
}
QCheckBox::indicator:checked:disabled,
QCheckBox::indicator:indeterminate:disabled {
- background: {{ colors.alpha(tokens.primary, 0.5) }};
- border-color: {{ colors.alpha(tokens.primary, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.primary, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.primary, 0.5) }};
}
QCheckBox:disabled {
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
QCheckBox[invalid="true"] {
@@ -526,7 +525,7 @@ QCheckBox[invalid="true"]::indicator {
}
QCheckBox[invalid="true"]::indicator:hover {
- border-color: {{ colors.alpha(tokens.destructive, 0.8) }};
+ border-color: {{ Colors.withAlpha(tokens.destructive, 0.8) }};
}
QCheckBox[invalid="true"]::indicator:checked,
@@ -547,27 +546,27 @@ QCheckBox[invalid="true"]::indicator:indeterminate {
QRadioButton
============================================================ */
-{% set radio_bg = colors.alpha(tokens.input, 0.3) if is_dark else "transparent" %}
-{% set invalid_border = colors.alpha(tokens.destructive, 0.5) if is_dark else tokens.destructive %}
-{% set invalid_outline = colors.alpha(tokens.destructive, 0.4 if is_dark else 0.2) %}
+{% set radio_bg = Colors.withAlpha(tokens.input, 0.3) if is_dark else "transparent" %}
+{% set invalid_border = Colors.withAlpha(tokens.destructive, 0.5) if is_dark else tokens.destructive %}
+{% set invalid_outline = Colors.withAlpha(tokens.destructive, 0.4 if is_dark else 0.2) %}
QRadioButton {
background: transparent;
color: {{ tokens.foreground }};
- spacing: {{ scale.spacing_px(tokens.spacing, 2) }};
- font-size: {{ scale.TEXT_SM }};
- font-weight: {{ scale.FONT_WEIGHT_MEDIUM }};
+ spacing: {{ Scale.resolveSpacing(tokens.spacing, 2) }};
+ font-size: {{ Typography.FontSize.SM }};
+ font-weight: {{ Typography.FontWeight.MEDIUM }};
}
QRadioButton::indicator {
- width: {{ scale.spacing_px(tokens.spacing, 4) }};
- height: {{ scale.spacing_px(tokens.spacing, 4) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ height: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
border: 1px solid {{ tokens.input }};
- border-radius: {{ scale.spacing_px(tokens.spacing, 2) }};
+ border-radius: {{ Scale.resolveSpacing(tokens.spacing, 2) }};
background: {{ radio_bg }};
}
-QRadioButton::indicator:hover { border-color: {{ colors.alpha(tokens.ring, 0.5) }}; }
+QRadioButton::indicator:hover { border-color: {{ Colors.withAlpha(tokens.ring, 0.5) }}; }
QRadioButton::indicator:checked {
background: {{ tokens.primary }};
@@ -576,17 +575,17 @@ QRadioButton::indicator:checked {
}
QRadioButton::indicator:disabled {
- background: {{ colors.alpha(tokens.muted, 0.5) }};
- border-color: {{ colors.alpha(tokens.input, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.muted, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.input, 0.5) }};
}
QRadioButton::indicator:checked:disabled {
- background: {{ colors.alpha(tokens.primary, 0.5) }};
- border-color: {{ colors.alpha(tokens.primary, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.primary, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.primary, 0.5) }};
}
QRadioButton:disabled {
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
QRadioButton[invalid="true"] { color: {{ tokens.destructive }}; }
@@ -597,7 +596,7 @@ QRadioButton[invalid="true"]::indicator {
}
QRadioButton[invalid="true"]::indicator:hover {
- border-color: {{ colors.alpha(tokens.destructive, 0.8) }};
+ border-color: {{ Colors.withAlpha(tokens.destructive, 0.8) }};
}
QRadioButton[invalid="true"]::indicator:checked {
@@ -610,46 +609,46 @@ QRadioButton[invalid="true"]::indicator:checked {
QComboBox
============================================================ */
-{% set combo_bg = colors.alpha(tokens.input, 0.3) if is_dark else "transparent" %}
-{% set combo_disabled_bg = colors.alpha(tokens.input, 0.8 if is_dark else 0.5) %}
-{% set invalid_border = colors.alpha(tokens.destructive, 0.5) if is_dark else tokens.destructive %}
-{% set invalid_outline = colors.alpha(tokens.destructive, 0.4 if is_dark else 0.2) %}
+{% set combo_bg = Colors.withAlpha(tokens.input, 0.3) if is_dark else "transparent" %}
+{% set combo_disabled_bg = Colors.withAlpha(tokens.input, 0.8 if is_dark else 0.5) %}
+{% set invalid_border = Colors.withAlpha(tokens.destructive, 0.5) if is_dark else tokens.destructive %}
+{% set invalid_outline = Colors.withAlpha(tokens.destructive, 0.4 if is_dark else 0.2) %}
QComboBox, QFontComboBox {
background: {{ combo_bg }};
color: {{ tokens.foreground }};
border: 1px solid {{ tokens.input }};
- border-radius: {{ radius.class_px(tokens.radius, "lg") }};
- padding: 0 {{ scale.spacing_px(tokens.spacing, 8) }} 0 {{ scale.spacing_px(tokens.spacing, 2.5) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 8) }};
- font-size: {{ scale.TEXT_SM }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
+ padding: 0 {{ Scale.resolveSpacing(tokens.spacing, 8) }} 0 {{ Scale.resolveSpacing(tokens.spacing, 2.5) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
+ font-size: {{ Typography.FontSize.SM }};
selection-background-color: {{ tokens.primary }};
selection-color: {{ tokens.primary_foreground }};
}
QComboBox:focus, QFontComboBox:focus {
border-color: {{ tokens.ring }};
- outline: 3px solid {{ colors.alpha(tokens.ring, 0.5) }};
+ outline: 3px solid {{ Colors.withAlpha(tokens.ring, 0.5) }};
}
QComboBox:disabled, QFontComboBox:disabled {
background: {{ combo_disabled_bg }};
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
- border-color: {{ colors.alpha(tokens.input, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.input, 0.5) }};
}
QComboBox::drop-down, QFontComboBox::drop-down {
- width: {{ scale.spacing_px(tokens.spacing, 8) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
border: 0;
}
QComboBox::down-arrow, QFontComboBox::down-arrow {
- width: {{ scale.spacing_px(tokens.spacing, 4) }};
- height: {{ scale.spacing_px(tokens.spacing, 4) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ height: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
image: url("{{ icons.chevron_down(tokens.muted_foreground) }}");
}
QComboBox:disabled::down-arrow, QFontComboBox:disabled::down-arrow {
- image: url("{{ icons.chevron_down(colors.alpha(tokens.muted_foreground, 0.5)) }}");
+ image: url("{{ icons.chevron_down(Colors.withAlpha(tokens.muted_foreground, 0.5)) }}");
}
QComboBox[invalid="true"], QFontComboBox[invalid="true"] {
@@ -666,36 +665,36 @@ QComboBox QAbstractItemView, QFontComboBox QAbstractItemView {
background: {{ tokens.popover }};
color: {{ tokens.popover_foreground }};
border: 1px solid {{ tokens.border }};
- border-radius: {{ radius.class_px(tokens.radius, "lg") }};
- padding: {{ scale.spacing_px(tokens.spacing, 1) }};
- font-size: {{ scale.TEXT_SM }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
+ padding: {{ Scale.resolveSpacing(tokens.spacing, 1) }};
+ font-size: {{ Typography.FontSize.SM }};
selection-background-color: {{ tokens.accent }};
selection-color: {{ tokens.accent_foreground }};
outline: none;
}
QComboBox QAbstractItemView::item, QFontComboBox QAbstractItemView::item {
- padding: {{ scale.spacing_px(tokens.spacing, 1) }} {{ scale.spacing_px(tokens.spacing, 8) }} {{ scale.spacing_px(tokens.spacing, 1) }} {{ scale.spacing_px(tokens.spacing, 1.5) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 8) }};
+ padding: {{ Scale.resolveSpacing(tokens.spacing, 1) }} {{ Scale.resolveSpacing(tokens.spacing, 8) }} {{ Scale.resolveSpacing(tokens.spacing, 1) }} {{ Scale.resolveSpacing(tokens.spacing, 1.5) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
}
QComboBox QAbstractItemView::item:selected, QFontComboBox QAbstractItemView::item:selected {
background: {{ tokens.accent }};
color: {{ tokens.accent_foreground }};
- border-radius: {{ radius.class_px(tokens.radius, "md") }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "md") }};
}
QComboBox QAbstractItemView::item:disabled, QFontComboBox QAbstractItemView::item:disabled {
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
/* ============================================================
QLineEdit / QTextEdit / QPlainTextEdit
============================================================ */
-{% set input_bg = colors.alpha(tokens.input, 0.31) if is_dark else "transparent" %}
-{% set invalid_border = colors.alpha(tokens.destructive, 0.5) if is_dark else tokens.destructive %}
-{% set invalid_outline = colors.alpha(tokens.destructive, 0.4 if is_dark else 0.2) %}
+{% set input_bg = Colors.withAlpha(tokens.input, 0.31) if is_dark else "transparent" %}
+{% set invalid_border = Colors.withAlpha(tokens.destructive, 0.5) if is_dark else tokens.destructive %}
+{% set invalid_outline = Colors.withAlpha(tokens.destructive, 0.4 if is_dark else 0.2) %}
QLineEdit,
QTextEdit,
@@ -704,38 +703,38 @@ QPlainTextEdit {
color: {{ tokens.foreground }};
border: 1px solid {{ tokens.input }};
outline: none;
- border-radius: {{ radius.class_px(tokens.radius, "lg") }};
- font-size: {{ scale.TEXT_SM }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
+ font-size: {{ Typography.FontSize.SM }};
placeholder-text-color: {{ tokens.muted_foreground }};
selection-background-color: {{ tokens.primary }};
selection-color: {{ tokens.primary_foreground }};
}
QLineEdit {
- padding: 0 {{ scale.spacing_px(tokens.spacing, 2.5) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 8) }};
+ padding: 0 {{ Scale.resolveSpacing(tokens.spacing, 2.5) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
}
QTextEdit,
QPlainTextEdit {
- padding: {{ scale.spacing_px(tokens.spacing, 2) }} {{ scale.spacing_px(tokens.spacing, 2.5) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 16) }};
+ padding: {{ Scale.resolveSpacing(tokens.spacing, 2) }} {{ Scale.resolveSpacing(tokens.spacing, 2.5) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 16) }};
}
QLineEdit:focus,
QTextEdit:focus,
QPlainTextEdit:focus {
- border: 3px solid {{ colors.alpha(tokens.ring, 0.5) }};
- outline: 3px solid {{ colors.alpha(tokens.ring, 0.5) }};
+ border: 3px solid {{ Colors.withAlpha(tokens.ring, 0.5) }};
+ outline: 3px solid {{ Colors.withAlpha(tokens.ring, 0.5) }};
}
QLineEdit:disabled,
QTextEdit:disabled,
QPlainTextEdit:disabled {
- background: {{ colors.alpha(tokens.input, 0.8 if is_dark else 0.5) }};
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
- border-color: {{ colors.alpha(tokens.input, 0.5) }};
- placeholder-text-color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.input, 0.8 if is_dark else 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.input, 0.5) }};
+ placeholder-text-color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
QLineEdit[invalid="true"],
@@ -756,27 +755,27 @@ QDoubleSpinBox {
color: {{ tokens.foreground }};
border: 1px solid {{ tokens.input }};
outline: none;
- border-radius: {{ radius.class_px(tokens.radius, "lg") }};
- font-size: {{ scale.TEXT_SM }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
+ font-size: {{ Typography.FontSize.SM }};
placeholder-text-color: {{ tokens.muted_foreground }};
selection-background-color: {{ tokens.primary }};
selection-color: {{ tokens.primary_foreground }};
- padding: 0 {{ scale.spacing_px(tokens.spacing, 8) }} 0 {{ scale.spacing_px(tokens.spacing, 2.5) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 8) }};
+ padding: 0 {{ Scale.resolveSpacing(tokens.spacing, 8) }} 0 {{ Scale.resolveSpacing(tokens.spacing, 2.5) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
}
QSpinBox:focus,
QDoubleSpinBox:focus {
border-color: {{ tokens.ring }};
- outline: 3px solid {{ colors.alpha(tokens.ring, 0.5) }};
+ outline: 3px solid {{ Colors.withAlpha(tokens.ring, 0.5) }};
}
QSpinBox:disabled,
QDoubleSpinBox:disabled {
- background: {{ colors.alpha(tokens.input, 0.8 if is_dark else 0.5) }};
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
- border-color: {{ colors.alpha(tokens.input, 0.5) }};
- placeholder-text-color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.input, 0.8 if is_dark else 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
+ border-color: {{ Colors.withAlpha(tokens.input, 0.5) }};
+ placeholder-text-color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
QSpinBox[invalid="true"],
@@ -788,10 +787,10 @@ QDoubleSpinBox[invalid="true"] {
QSpinBox::up-button,
QDoubleSpinBox::up-button {
- width: {{ scale.spacing_px(tokens.spacing, 8) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
border: none;
background: transparent;
- border-top-right-radius: {{ radius.class_px(tokens.radius, "lg") }};
+ border-top-right-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
border-top-left-radius: 0px;
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
@@ -800,10 +799,10 @@ QDoubleSpinBox::up-button {
QSpinBox::down-button,
QDoubleSpinBox::down-button {
- width: {{ scale.spacing_px(tokens.spacing, 8) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 8) }};
border: none;
background: transparent;
- border-bottom-right-radius: {{ radius.class_px(tokens.radius, "lg") }};
+ border-bottom-right-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
border-top-right-radius: 0px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
@@ -828,20 +827,20 @@ QSpinBox::up-button:disabled,
QDoubleSpinBox::up-button:disabled,
QSpinBox::down-button:disabled,
QDoubleSpinBox::down-button:disabled {
- background: {{ colors.alpha(tokens.muted, 0.5) }};
+ background: {{ Colors.withAlpha(tokens.muted, 0.5) }};
}
QSpinBox::up-arrow,
QDoubleSpinBox::up-arrow {
- width: {{ scale.spacing_px(tokens.spacing, 4) }};
- height: {{ scale.spacing_px(tokens.spacing, 4) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ height: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
image: url("{{ icons.chevron_up(tokens.muted_foreground) }}");
}
QSpinBox::down-arrow,
QDoubleSpinBox::down-arrow {
- width: {{ scale.spacing_px(tokens.spacing, 4) }};
- height: {{ scale.spacing_px(tokens.spacing, 4) }};
+ width: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ height: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
image: url("{{ icons.chevron_down(tokens.muted_foreground) }}");
}
@@ -857,12 +856,12 @@ QDoubleSpinBox::down-arrow:hover {
QSpinBox::up-arrow:disabled,
QDoubleSpinBox::up-arrow:disabled {
- image: url("{{ icons.chevron_up(colors.alpha(tokens.muted_foreground, 0.5)) }}");
+ image: url("{{ icons.chevron_up(Colors.withAlpha(tokens.muted_foreground, 0.5)) }}");
}
QSpinBox::down-arrow:disabled,
QDoubleSpinBox::down-arrow:disabled {
- image: url("{{ icons.chevron_down(colors.alpha(tokens.muted_foreground, 0.5)) }}");
+ image: url("{{ icons.chevron_down(Colors.withAlpha(tokens.muted_foreground, 0.5)) }}");
}
QSpinBox[invalid="true"]::up-arrow,
@@ -879,43 +878,45 @@ QDoubleSpinBox[invalid="true"]::down-arrow {
QProgressBar
============================================================ */
+{% set progress_bar_height = Scale.resolveSpacing(tokens.spacing, 8) %}
+
QProgressBar {
background-color: {{ tokens.muted }};
border: 0px;
- border-radius: {{ radius.class_px(tokens.radius, "full") }};
- min-height: {{ scale.spacing_px(tokens.spacing, 1) }};
- max-height: {{ scale.spacing_px(tokens.spacing, 1) }};
+ border-radius: {{ radius.halveRadius(progress_bar_height) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 1) }};
+ max-height: {{ Scale.resolveSpacing(tokens.spacing, 1) }};
text-align: center;
color: transparent;
}
QProgressBar::chunk {
background-color: {{ tokens.primary }};
- border-radius: {{ radius.class_px(tokens.radius, "full") }};
+ border-radius: {{ radius.halveRadius(progress_bar_height) }};
margin: 0;
}
QProgressBar[labeled="true"] {
color: {{ tokens.accent }};
- min-height: {{ scale.spacing_px(tokens.spacing, 4) }};
- max-height: {{ scale.spacing_px(tokens.spacing, 4) }};
- border-radius: {{ radius.class_px(tokens.radius, "md") }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ max-height: {{ Scale.resolveSpacing(tokens.spacing, 4) }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "md") }};
}
QProgressBar::chunk[labeled="true"] {
- border-radius: {{ radius.class_px(tokens.radius, "md") }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "md") }};
}
-QProgressBar:disabled { background-color: {{ colors.alpha(tokens.muted, 0.5) }}; }
+QProgressBar:disabled { background-color: {{ Colors.withAlpha(tokens.muted, 0.5) }}; }
-QProgressBar::chunk:disabled { background-color: {{ colors.alpha(tokens.primary, 0.5) }}; }
+QProgressBar::chunk:disabled { background-color: {{ Colors.withAlpha(tokens.primary, 0.5) }}; }
/* ============================================================
QSlider
============================================================ */
-{% set groove = scale.spacing_int(tokens.spacing, 1) %}
-{% set handle = scale.spacing_int(tokens.spacing, 3) %}
+{% set groove = Scale.toSpacingInt(tokens.spacing, 1) %}
+{% set handle = Scale.toSpacingInt(tokens.spacing, 3) %}
{% set offset = (handle - groove) // 2 %}
QSlider:horizontal { min-height: {{ handle }}px; }
@@ -925,14 +926,14 @@ QSlider::groove:horizontal {
height: {{ groove }}px;
margin: 0;
background: {{ tokens.muted }};
- border-radius: {{ radius.class_px(tokens.radius, "full") }};
+ border-radius: {{ radius.halveRadius(Scale.resolveSpacing(tokens.spacing, 1)) }};
}
QSlider::groove:vertical {
width: {{ groove }}px;
margin: 0;
background: {{ tokens.muted }};
- border-radius: {{ radius.class_px(tokens.radius, "full") }};
+ border-radius: {{ radius.halveRadius(Scale.resolveSpacing(tokens.spacing, 1)) }};
}
QSlider::handle {
@@ -952,19 +953,17 @@ QSlider::handle:hover {
QSlider::add-page {
background: {{ tokens.muted }};
- border-radius: {{ radius.class_px(tokens.radius, "full") }};
}
QSlider::sub-page {
background: {{ tokens.primary }};
- border-radius: {{ radius.class_px(tokens.radius, "full") }};
}
QSlider::handle:disabled {
image: url({{ icons.slider_thumb(tokens.input, tokens.input, handle) }});
}
-QSlider::sub-page:disabled { background: {{ colors.alpha(tokens.input, 0.5) }}; }
+QSlider::sub-page:disabled { background: {{ Colors.withAlpha(tokens.input, 0.5) }}; }
/* ============================================================
QTabWidget
@@ -976,26 +975,26 @@ QTabWidget::pane {
QTabBar {
background: {{ tokens.muted }};
- border-radius: {{ radius.class_px(tokens.radius, "lg") }};
- padding: {{ scale.spacing_px(tokens.spacing, 1) }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "lg") }};
+ padding: {{ Scale.resolveSpacing(tokens.spacing, 1) }};
}
QTabBar::tab {
background: transparent;
color: {{ tokens.muted_foreground }};
border: 1px solid transparent;
- border-radius: {{ radius.class_px(tokens.radius, "md") }};
- margin: {{ scale.spacing_px(tokens.spacing, 0.7) }};
- padding: {{ scale.spacing_px(tokens.spacing, 0.5) }} {{ scale.spacing_px(tokens.spacing, 3) }};
- min-height: {{ scale.spacing_px(tokens.spacing, 7) }};
- font-size: {{ scale.TEXT_SM }};
+ border-radius: {{ radius.resolveRadius(tokens.radius, "md") }};
+ margin: {{ Scale.resolveSpacing(tokens.spacing, 0.7) }};
+ padding: {{ Scale.resolveSpacing(tokens.spacing, 0.5) }} {{ Scale.resolveSpacing(tokens.spacing, 3) }};
+ min-height: {{ Scale.resolveSpacing(tokens.spacing, 7) }};
+ font-size: {{ Typography.FontSize.SM }};
}
QTabBar::tab:selected {
background: {{ tokens.background }};
color: {{ tokens.foreground }};
border-color: {{ tokens.muted }};
- font-weight: {{ scale.FONT_WEIGHT_BOLD }};
+ font-weight: {{ Typography.FontWeight.BOLD }};
}
QTabBar::tab:hover {
@@ -1003,7 +1002,7 @@ QTabBar::tab:hover {
}
QTabBar::tab:disabled {
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
QTabWidget[variant="line"] QTabBar {
@@ -1018,7 +1017,7 @@ QTabWidget[variant="line"] QTabBar::tab {
border: none;
border-bottom: 2px solid transparent;
border-radius: 0px;
- padding: {{ scale.spacing_px(tokens.spacing, 1) }} {{ scale.spacing_px(tokens.spacing, 3) }};
+ padding: {{ Scale.resolveSpacing(tokens.spacing, 1) }} {{ Scale.resolveSpacing(tokens.spacing, 3) }};
}
QTabWidget[variant="line"] QTabBar::tab:selected {
@@ -1032,7 +1031,7 @@ QTabWidget[variant="line"] QTabBar::tab:hover {
}
QTabWidget[variant="line"] QTabBar::tab:disabled {
- color: {{ colors.alpha(tokens.muted_foreground, 0.5) }};
+ color: {{ Colors.withAlpha(tokens.muted_foreground, 0.5) }};
}
QTabWidget[variant="line"] QTabBar::tab:left {
diff --git a/qtshadcn/themes/default.xml b/qtshadcn/themes/default.xml
index 6d23d87..0ed8052 100644
--- a/qtshadcn/themes/default.xml
+++ b/qtshadcn/themes/default.xml
@@ -21,7 +21,7 @@
#a1a1a1
4px
8px
- Open Sans, ui-sans-serif, sans-serif, system-ui
+ Open Sans
#0a0a0a
@@ -45,6 +45,6 @@
#737373
4px
8px
- Open Sans, ui-sans-serif, sans-serif, system-ui
+ Open Sans
diff --git a/qtshadcn/tokens/__init__.py b/qtshadcn/tokens/__init__.py
index 8b9d622..425b739 100644
--- a/qtshadcn/tokens/__init__.py
+++ b/qtshadcn/tokens/__init__.py
@@ -1,5 +1,5 @@
"""Static design-scale tokens for QtShadcn stylesheets."""
-from . import colors, radius, scale
+from . import colors, radius, scale, typography
-__all__ = ["colors", "radius", "scale"]
+__all__ = ["colors", "radius", "scale", "typography"]
diff --git a/qtshadcn/tokens/colors.py b/qtshadcn/tokens/colors.py
index aba3af4..aeac2e9 100644
--- a/qtshadcn/tokens/colors.py
+++ b/qtshadcn/tokens/colors.py
@@ -5,6 +5,11 @@
logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
_HEX_RE = re.compile(r"^#(?P[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
_RGB_RE = re.compile(
r"^rgba?\(\s*(?P\d+)\s*,\s*(?P\d+)\s*,\s*(?P\d+)"
@@ -12,35 +17,94 @@
)
-def alpha(color: str, opacity: float) -> str:
- """Return ``color`` with a CSS-like opacity as Qt-compatible ``rgba()``."""
- parsed = _parse_rgb(color)
- if parsed is None:
- logger.warning("Cannot parse color for alpha: %s", color)
- return color
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+def withAlpha(baseColor: str, alphaValue: float) -> str:
+ """Return `baseColor` with a CSS-like opacity as a Qt-compatible `rgba()` string.
+ Useful for hover/pressed states where the design token must be
+ translucent without introducing a new color.
+
+ Args:
+ baseColor (str): Base color in hex or rgb format, e.g. "#3b82f6" or "rgb(59, 130, 246)".
+ alphaValue (float): Opacity in the range [0.0, 1.0]. Values outside this range are clamped.
+
+ Returns:
+ str: A Qt-compatible `rgba(r, g, b, a)` string. If `baseColor`
+ cannot be parsed, the original string is returned unchanged
+ and a warning is logged.
+
+ Examples:
+ >>> withAlpha("#000000", 0.5)
+ 'rgba(0, 0, 0, 0.5)'
+ >>> withAlpha("rgb(255, 0, 0)", 1.0)
+ 'rgba(255, 0, 0, 1)'
+ """
+ parsed = _parseColor(baseColor)
+ if parsed is None:
+ logger.warning("Cannot parse color for alpha: %s", baseColor)
+ return baseColor
r, g, b = parsed
- return f"rgba({r}, {g}, {b}, {_format_float(_clamp(opacity))})"
+ return f"rgba({r}, {g}, {b}, {_toAlphaString(_clampUnit(alphaValue))})"
-def mix(color: str, other: str, amount: float) -> str:
- """Return a linear RGB mix of ``color`` toward ``other`` by ``amount``."""
- first = _parse_rgb(color)
- second = _parse_rgb(other)
- if first is None or second is None:
- logger.warning("Cannot parse colors for mix: %s, %s", color, other)
- return color
+def blendColors(sourceColor: str, targetColor: str, blendAmount: float) -> str:
+ """Return a linear RGB mix of `sourceColor` toward `targetColor` by `blendAmount`.
- amount = _clamp(amount)
- r = round(first[0] * (1 - amount) + second[0] * amount)
- g = round(first[1] * (1 - amount) + second[1] * amount)
- b = round(first[2] * (1 - amount) + second[2] * amount)
+ Used to derive intermediate states (hover, active, disabled) from
+ two design tokens without hard-coding hex values.
+
+ Args:
+ sourceColor (str): Starting color in hex or rgb format.
+ targetColor (str): Ending color in hex or rgb format.
+ blendAmount (float): Interpolation factor in [0.0, 1.0]. 0.0 returns `sourceColor`; 1.0 returns `targetColor`.
+
+ Returns:
+ str: A Qt-compatible `rgb(r, g, b)` string. If either color
+ cannot be parsed, `sourceColor` is returned unchanged and
+ a warning is logged.
+
+ Examples:
+ >>> blendColors("#000000", "#ffffff", 0.5)
+ 'rgb(128, 128, 128)'
+ >>> blendColors("rgb(255, 0, 0)", "rgb(0, 0, 255)", 0.0)
+ 'rgb(255, 0, 0)'
+ """
+ first = _parseColor(sourceColor)
+ second = _parseColor(targetColor)
+ if first is None or second is None:
+ logger.warning("Cannot parse colors for mix: %s, %s", sourceColor, targetColor)
+ return sourceColor
+ blendAmount = _clampUnit(blendAmount)
+ r = round(first[0] * (1 - blendAmount) + second[0] * blendAmount)
+ g = round(first[1] * (1 - blendAmount) + second[1] * blendAmount)
+ b = round(first[2] * (1 - blendAmount) + second[2] * blendAmount)
return f"rgb({r}, {g}, {b})"
-def _parse_rgb(color: str) -> tuple[int, int, int] | None:
- value = color.strip()
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+def _parseColor(colorString: str) -> tuple[int, int, int] | None:
+ """Parse a hex or rgb color string into its (r, g, b) components.
+
+ Supports 3-digit hex (`#abc`), 6-digit hex (`#aabbcc`), `rgb()` and
+ `rgba()` formats. Alpha channel is ignored because this helper only
+ returns the RGB triple.
+
+ Args:
+ colorString (str): Color string to parse.
+
+ Returns:
+ tuple[int, int, int] | None: The (r, g, b) components, or None
+ if the format is unrecognized (a debug message is logged).
+ """
+ value = colorString.strip()
hex_match = _HEX_RE.match(value)
if hex_match is not None:
hex_value = hex_match.group("hex")
@@ -51,12 +115,10 @@ def _parse_rgb(color: str) -> tuple[int, int, int] | None:
int(hex_value[2:4], 16),
int(hex_value[4:6], 16),
)
-
rgb_match = _RGB_RE.match(value)
if rgb_match is None:
- logger.debug("Unrecognized color format: %s", color)
+ logger.debug("Unrecognized color format: %s", colorString)
return None
-
return (
int(rgb_match.group("r")),
int(rgb_match.group("g")),
@@ -64,9 +126,11 @@ def _parse_rgb(color: str) -> tuple[int, int, int] | None:
)
-def _clamp(value: float) -> float:
- return max(0, min(1, value))
+def _clampUnit(unitValue: float) -> float:
+ """Clamp `unitValue` to the unit interval [0.0, 1.0]."""
+ return max(0, min(1, unitValue))
-def _format_float(value: float) -> str:
- return f"{value:.2f}".rstrip("0").rstrip(".")
+def _toAlphaString(alphaValue: float) -> str:
+ """Format `alphaValue` as a compact decimal string, dropping trailing zeros."""
+ return f"{alphaValue:.2f}".rstrip("0").rstrip(".")
diff --git a/qtshadcn/tokens/radius.py b/qtshadcn/tokens/radius.py
index 151d452..8f10b76 100644
--- a/qtshadcn/tokens/radius.py
+++ b/qtshadcn/tokens/radius.py
@@ -1,73 +1,153 @@
-"""Qt-safe helpers for shadcn/Tailwind radius semantics."""
+"""Static shadcn/Tailwind-inspired design scale tokens."""
import re
-XS_RADIUS = "2px"
-FULL_RADIUS = "9999px"
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+def resolveRadius(baseRadius: str, radiusSize: str) -> str:
+ """Map a Tailwind rounded-{name} class to a static pixel value.
+
+ The `sm` and `md` tiers are derived from baseRadius so they
+ track theme customisation; the rest are fixed shadcn defaults.
+
+ Args:
+ baseRadius (str): Base radius in pixels, e.g. "8px".
+ radiusSize (str): One of "none" | "xs" | "sm" | "md" | "lg" | "xl" |
+ "2xl" | "3xl" | "4xl" | "full".
+
+ Returns:
+ str: Static pixel value for the given radius class.
+
+ Raises:
+ ValueError: If name is not one of the accepted radius classes.
-SM_RATIO = 0.6
-MD_RATIO = 0.8
-LG_RATIO = 1
-XL_RATIO = 1.4
-RADIUS_2XL_RATIO = 1.8
-RADIUS_3XL_RATIO = 2.2
-RADIUS_4XL_RATIO = 2.6
+ Examples:
+ >>> resolveRadius("8px", "none")
+ '0px'
+ >>> resolveRadius("8px", "sm") # sm = base - 4
+ '4px'
+ >>> resolveRadius("8px", "md") # md = base - 2
+ '6px'
+ >>> resolveRadius("8px", "lg") # lg = base
+ '8px'
-_PX_RE = re.compile(r"^(-?\d+(?:\.\d+)?)px$")
+ """
+ match radiusSize:
+ case "none":
+ return "0px"
+ case "xs":
+ return "2px"
+ case "sm":
+ return _toPixelString(_parsePixel(baseRadius) - 4)
+ case "md":
+ return _toPixelString(_parsePixel(baseRadius) - 2)
+ case "lg":
+ return _toPixelString(_parsePixel(baseRadius))
+ case "xl":
+ return _toPixelString(_parsePixel(baseRadius) + 4)
+ case "2xl":
+ return "16px"
+ case "3xl":
+ return "24px"
+ case "4xl":
+ return "32px"
+ case "full":
+ return "9999px"
+ case _:
+ raise ValueError(f"Unsupported radius class: {radiusSize}")
-_RATIOS = {
- "sm": SM_RATIO,
- "md": MD_RATIO,
- "lg": LG_RATIO,
- "xl": XL_RATIO,
- "2xl": RADIUS_2XL_RATIO,
- "3xl": RADIUS_3XL_RATIO,
- "4xl": RADIUS_4XL_RATIO,
-}
+def clampRadius(baseRadius: str, radiusSize: str, maxRadius: str) -> str:
+ """Clamp a rounded-* radius to limit.
-def class_px(base_radius: str, name: str) -> str:
- """Return the static pixel value for a Tailwind rounded-* class name."""
- if name == "none":
- return "0px"
- if name == "xs":
- return XS_RADIUS
- if name == "full":
- return FULL_RADIUS
- if name not in _RATIOS:
- raise ValueError(f"Unsupported radius class: {name}")
+ Needed for pill-shaped widgets where the design token may exceed the
+ widget height — Qt renders that as an invisible rounded rectangle.
- return _format_px(_px_number(base_radius) * _RATIOS[name])
+ Args:
+ baseRadius (str): Base radius in pixels.
+ radiusSize (str): Radius class name (e.g. "xl").
+ maxRadius (str): Maximum allowed radius in pixels.
+ Returns:
+ str: The clamped pixel value.
-def min_px(base_radius: str, name: str, limit: str) -> str:
- """Return ``min(rounded-name, limit)`` as a static Qt-safe pixel value."""
- return _format_px(min(_px_number(class_px(base_radius, name)), _px_number(limit)))
+ Examples:
+ >>> clampRadius("8px", "xl", "10px") # xl=12px, clamped to 10px
+ '10px'
+ """
+ return _toPixelString(
+ min(_parsePixel(resolveRadius(baseRadius, radiusSize)), _parsePixel(maxRadius))
+ )
-def add_px(base_radius: str, name: str, amount: str) -> str:
- """Return ``rounded-name + amount`` as a static Qt-safe pixel value."""
- return _format_px(_px_number(class_px(base_radius, name)) + _px_number(amount))
+def expandRadius(baseRadius: str, radiusSize: str, pixelOffset: str) -> str:
+ """Offset a rounded-* radius outward, typically to match a parent's border.
-def sub_px(base_radius: str, name: str, amount: str) -> str:
- """Return ``rounded-name - amount`` as a non-negative static pixel value."""
- return _format_px(max(_px_number(class_px(base_radius, name)) - _px_number(amount), 0))
+ Args:
+ baseRadius (str): Base radius in pixels.
+ radiusSize (str): Radius class name.
+ pixelOffset (str): Pixels to add.
+ Returns:
+ str: The increased pixel value.
-def half_px(value: str) -> str:
- """Return half of a Qt-safe pixel value."""
- return _format_px(_px_number(value) / 2)
+ """
+ return _toPixelString(
+ _parsePixel(resolveRadius(baseRadius, radiusSize)) + _parsePixel(pixelOffset)
+ )
-def _px_number(value: str) -> float:
- match = _PX_RE.match(value.strip())
+def shrinkRadius(baseRadius: str, radiusSize: str, pixelOffset: str) -> str:
+ """Offset a rounded-* radius inward, floored at 0 to stay Qt-safe.
+
+ Args:
+ baseRadius (str): Base radius in pixels.
+ radiusSize (str): Radius class name.
+ pixelOffset (str): Pixels to subtract.
+
+ Returns:
+ str: The decreased pixel value (never negative).
+
+ """
+ return _toPixelString(
+ max(_parsePixel(resolveRadius(baseRadius, radiusSize)) - _parsePixel(pixelOffset), 0)
+ )
+
+
+def halveRadius(pixelValue: str) -> str:
+ """Halve a pixel value — intermediate step for circle math.
+
+ QSS doesn't support 50%, so we need an explicit pixel value.
+
+ Args:
+ pixelValue (str): Pixel value to halve (e.g. "32px").
+
+ Returns:
+ str: Half of the input value as a pixel string.
+
+ """
+ return _toPixelString(_parsePixel(pixelValue) / 2)
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+def _parsePixel(pixelString: str) -> float:
+ """Parse a `"px"` string into its numeric value."""
+ match = re.compile(r"^(-?\d+(?:\.\d+)?)px$").match(pixelString.strip())
if match is None:
- raise ValueError(f"Expected a pixel value, got: {value}")
+ raise ValueError(f"Expected a pixel value, got: {pixelString}")
return float(match.group(1))
-def _format_px(value: float) -> str:
- if value % 1 == 0:
- return f"{int(value)}px"
- return f"{value:.2f}".rstrip("0").rstrip(".") + "px"
+def _toPixelString(numericValue: float) -> str:
+ """Format a float as a pixel string, dropping unnecessary decimals."""
+ if numericValue % 1 == 0:
+ return f"{int(numericValue)}px"
+ return f"{numericValue:.2f}".rstrip("0").rstrip(".") + "px"
diff --git a/qtshadcn/tokens/scale.py b/qtshadcn/tokens/scale.py
index f0060de..4fa5f7c 100644
--- a/qtshadcn/tokens/scale.py
+++ b/qtshadcn/tokens/scale.py
@@ -1,91 +1,79 @@
-"""Static shadcn/Tailwind-inspired design scale tokens.
-
-These values represent stable system-scale tokens, not theme-specific color
-tokens. They are resolved to Qt/QSS-friendly values because Qt stylesheets do
-not support CSS custom properties, ``rem`` units, or ``calc()`` expressions.
-"""
+"""Static shadcn/Tailwind-inspired design scale tokens."""
+import logging
import re
-REM_PX = 16
-
-BREAKPOINT_2XL = "1536px"
-
-CONTAINER_XS = "320px"
-CONTAINER_SM = "384px"
-CONTAINER_MD = "448px"
-CONTAINER_LG = "512px"
-CONTAINER_XL = "576px"
-CONTAINER_2XL = "672px"
-CONTAINER_3XL = "768px"
-CONTAINER_4XL = "896px"
-CONTAINER_5XL = "1024px"
-CONTAINER_6XL = "1152px"
-
-TEXT_XS = "12px"
-TEXT_XS_LINE_HEIGHT = 1 / 0.75
-TEXT_SM = "14px"
-TEXT_SM_LINE_HEIGHT = 1.25 / 0.875
-TEXT_BASE = "16px"
-TEXT_BASE_LINE_HEIGHT = 1.5
-TEXT_LG = "18px"
-TEXT_LG_LINE_HEIGHT = 1.75 / 1.125
-TEXT_XL = "20px"
-TEXT_XL_LINE_HEIGHT = 1.75 / 1.25
-TEXT_2XL = "24px"
-TEXT_2XL_LINE_HEIGHT = 2 / 1.5
-TEXT_3XL = "30px"
-TEXT_3XL_LINE_HEIGHT = 2.25 / 1.875
-TEXT_4XL = "36px"
-TEXT_4XL_LINE_HEIGHT = 2.5 / 2.25
-TEXT_5XL = "48px"
-TEXT_5XL_LINE_HEIGHT = 1
-TEXT_7XL = "72px"
-TEXT_7XL_LINE_HEIGHT = 1
-
-FONT_WEIGHT_LIGHT = 300
-FONT_WEIGHT_NORMAL = 400
-FONT_WEIGHT_MEDIUM = 500
-FONT_WEIGHT_SEMIBOLD = 600
-FONT_WEIGHT_BOLD = 700
-FONT_WEIGHT_EXTRABOLD = 800
-
-TRACKING_TIGHTER = "-0.05em"
-TRACKING_TIGHT = "-0.025em"
-TRACKING_NORMAL = "0em"
-TRACKING_WIDE = "0.025em"
-TRACKING_WIDER = "0.05em"
-TRACKING_WIDEST = "0.1em"
-
-LEADING_TIGHT = 1.25
-LEADING_SNUG = 1.375
-LEADING_NORMAL = 1.5
-LEADING_RELAXED = 1.625
-LEADING_LOOSE = 2
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
_PX_RE = re.compile(r"^(-?\d+(?:\.\d+)?)px$")
-def scale_px(value: str, factor: float) -> str:
- """Return a Qt-safe pixel value scaled by ``factor``."""
- match = _PX_RE.match(value.strip())
- if match is None:
- return value
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
- scaled = float(match.group(1)) * factor
- if scaled % 1 == 0:
- return f"{int(scaled)}px"
- return f"{scaled:.2f}".rstrip("0").rstrip(".") + "px"
+
+def resolveSpacing(pixelValue: str, scaleFactor: float) -> str:
+ """Scale a base spacing token by a multiplier and return a QSS-ready pixel string.
+
+ Args:
+ pixelValue (str): Base spacing token in pixels, e.g. "4px".
+ scaleFactor (float): Tailwind spacing multiplier (e.g. 2.5 → "10px" for a "4px" base).
+
+ Returns:
+ str: Scaled pixel value ready for QSS (e.g. "10px").
+
+ Examples:
+ >>> resolveSpacing("4px", 2)
+ '8px'
+ >>> resolveSpacing("4px", 2.5)
+ '10px'
+ """
+ return _scalePixel(pixelValue, scaleFactor)
-def spacing_px(value: str, multiple: float) -> str:
- """Return a Qt-safe spacing multiple."""
- return scale_px(value, multiple)
+def toSpacingInt(pixelValue: str, scaleFactor: float) -> int:
+ """Scale a base spacing token by a multiplier and return an integer pixel value.
+ Useful when Qt requires an integer (e.g. setContentsMargins, resize).
+ Falls back to 0 when pixelValue is not a valid pixel string.
-def spacing_int(value: str, multiple: float) -> int:
- """Return a spacing multiple as an integer pixel value."""
- match = _PX_RE.match(scale_px(value, multiple).strip())
+ Args:
+ pixelValue (str): Base spacing token in pixels, e.g. "4px".
+ scaleFactor (float): Tailwind spacing multiplier.
+
+ Returns:
+ int: Scaled pixel value as an integer, or 0 if pixelValue is not a valid pixel string.
+
+ Examples:
+ >>> toSpacingInt("4px", 2)
+ 8
+ >>> toSpacingInt("auto", 2)
+ 0
+ """
+ match = _PX_RE.match(_scalePixel(pixelValue, scaleFactor).strip())
if match is None:
+ logger.warning("Unrecognized pixel value format: %s", pixelValue)
return 0
return int(float(match.group(1)))
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+def _scalePixel(pixelString: str, scaleFactor: float) -> str:
+ """Return a Qt-safe pixel value scaled by `scaleFactor`."""
+ match = _PX_RE.match(pixelString.strip())
+ if match is None:
+ logger.warning("Unrecognized pixel value format: %s", pixelString)
+ return pixelString
+ scaled = float(match.group(1)) * scaleFactor
+ if scaled % 1 == 0:
+ return f"{int(scaled)}px"
+ return f"{scaled:.2f}".rstrip("0").rstrip(".") + "px"
diff --git a/qtshadcn/tokens/typography.py b/qtshadcn/tokens/typography.py
new file mode 100644
index 0000000..f141c00
--- /dev/null
+++ b/qtshadcn/tokens/typography.py
@@ -0,0 +1,40 @@
+"""Static shadcn/Tailwind-inspired typography design tokens."""
+
+from enum import IntEnum, StrEnum
+
+
+class FontSize(StrEnum):
+ """Shadcn/Tailwind text-* size scale; each member is a pixel string usable directly in QSS."""
+
+ XS = "12px"
+ SM = "14px"
+ BASE = "16px"
+ LG = "18px"
+ XL = "20px"
+ XL2 = "24px"
+ XL3 = "30px"
+ XL4 = "36px"
+ XL5 = "48px"
+ XL7 = "72px"
+
+ def __str__(self) -> str:
+ """Return the font size as a string for QSS."""
+ return str(self.value)
+
+
+class FontWeight(IntEnum):
+ """CSS numeric font-weight scale from Thin (100) to Black (900); members render as integers."""
+
+ THIN = 100
+ EXTRA_LIGHT = 200
+ LIGHT = 300
+ NORMAL = 400
+ MEDIUM = 500
+ SEMI_BOLD = 600
+ BOLD = 700
+ EXTRA_BOLD = 800
+ BLACK = 900
+
+ def __str__(self) -> str:
+ """Return the font weight as a string for QSS."""
+ return str(int(self))
diff --git a/tests/test_app.py b/tests/test_app.py
index 0ffae9e..d69c126 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -30,9 +30,9 @@
#e2e8f0
#e2e8f0
#0f172a
- 0.25rem
+ 4px
8px
- system-ui, sans-serif
+ Open Sans
#020617
@@ -54,9 +54,9 @@
#1e293b
#1e293b
#cbd5e1
- 0.25rem
+ 4px
8px
- system-ui, sans-serif
+ Open Sans
"""
@@ -188,16 +188,16 @@ def test_mode_specific_custom_tokens(self, qapp: QtWidgets.QApplication, sample_
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."""
+ def test_custom_tokens_are_applied(self, qapp: QtWidgets.QApplication, sample_xml: Path):
+ """Test that custom token values are applied unchanged."""
setThemeMode("light", save=False)
setTheme(
sample_xml,
- custom_tokens={"background": "oklch(0.21 0.006 285.885)"},
+ custom_tokens={"background": "#123456"},
save=False,
)
tokens = getTheme()
- assert tokens.background.startswith("rgb(")
+ assert tokens.background == "#123456"
class TestAdditionalQss:
diff --git a/tests/test_config.py b/tests/test_config.py
index dc93a1f..171b3d4 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,7 +1,6 @@
"""Tests for the QtShadcn persistent configuration system."""
import json
-import warnings
from qtpy import QtCore
from qtshadcn.common.config import ConfigItem, QtShadcnSettings, ThemeMode, qsettings
@@ -31,7 +30,7 @@ def _tokens(**overrides):
"ring": "#0f172a",
"spacing": "4px",
"radius": "8px",
- "font_family": "system-ui, sans-serif",
+ "font_family": "Open Sans",
}
values.update(overrides)
return values
@@ -67,6 +66,32 @@ def test_block_signal_prevents_emit(self, qtbot):
assert item.value == "dark"
assert received == []
+ def test_set_does_not_emit_when_value_unchanged(self, qtbot):
+ item = ConfigItem("mode", "auto")
+ received = []
+ item.valueChanged.connect(lambda v: received.append(v))
+ item.set("auto")
+ assert item.value == "auto"
+ assert received == []
+
+ def test_reset_emits_value_changed(self, qtbot):
+ item = ConfigItem("mode", "auto")
+ item.set("dark")
+ received = []
+ item.valueChanged.connect(lambda v: received.append(v))
+ item.reset()
+ assert item.value == "auto"
+ assert received == ["auto"]
+
+ def test_reset_block_signal_prevents_emit(self, qtbot):
+ item = ConfigItem("mode", "auto")
+ item.set("dark")
+ received = []
+ item.valueChanged.connect(lambda v: received.append(v))
+ item.reset(block_signal=True)
+ assert item.value == "auto"
+ assert received == []
+
class TestConfigDir:
def test_default_config_dir_uses_app_data_location(self, monkeypatch, tmp_path):
@@ -98,7 +123,6 @@ 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"),
}
@@ -137,7 +161,7 @@ def test_load_restores_theme_from_xml(self, tmp_path):
#0f172a
4px
8px
- system-ui, sans-serif
+ Open Sans
#020617
@@ -161,7 +185,7 @@ def test_load_restores_theme_from_xml(self, tmp_path):
#cbd5e1
4px
8px
- system-ui, sans-serif
+ Open Sans
"""
@@ -180,7 +204,18 @@ def test_load_restores_style_sheet(self, tmp_path):
qsettings.load()
- assert qsettings.additional_style_sheet.value == str(tmp_path / "style.qss")
+ assert qsettings.additional_style_sheet.value == "QWidget { color: red; }"
+
+ def test_load_then_save_preserves_style_sheet_content(self, tmp_path):
+ qsettings.reset_for_test()
+ qsettings.set_config_dir(tmp_path)
+ original = "QWidget { color: red; }"
+ (tmp_path / "style.qss").write_text(original, encoding="utf-8")
+
+ qsettings.load()
+ qsettings.save(only={"additional_style_sheet"})
+
+ assert (tmp_path / "style.qss").read_text(encoding="utf-8") == original
def test_load_with_config_dir_argument(self, tmp_path):
qsettings.reset_for_test()
@@ -226,23 +261,21 @@ def test_save_writes_theme_and_style(self, tmp_path):
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):
+ def test_corrupt_theme_mode_json_falls_back_to_auto(self, tmp_path, caplog):
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")
+ with caplog.at_level("WARNING", logger="qtshadcn.common.config"):
qsettings.load()
assert qsettings.theme_mode.value == ThemeMode.AUTO.value
- assert any("Corrupt theme_mode.json" in str(warning.message) for warning in w)
+ assert any("Corrupt theme_mode.json" in record.message for record in caplog.records)
def test_save_jinja_style_sheet(self, tmp_path):
qsettings.reset_for_test()
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 53e9ffe..f937e58 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -46,7 +46,7 @@ def test_theme_render_error_raised():
input="#cccccc",
ring="#000000",
spacing="4px",
- radius="0.5rem",
+ radius="8px",
font_family="Inter",
)
invalid_template = "{% if unclosed_tag %}"
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
new file mode 100644
index 0000000..27072ee
--- /dev/null
+++ b/tests/test_helpers.py
@@ -0,0 +1,20 @@
+"""Tests for qtshadcn helpers."""
+
+from pathlib import Path
+
+from qtshadcn.common.helpers import _atomic_write, _looks_like_jinja
+
+
+def test_looks_like_jinja():
+ assert _looks_like_jinja("{{ foo }}") is True
+ assert _looks_like_jinja("{% block %}") is True
+ assert _looks_like_jinja("QWidget { color: red; }") is False
+
+
+def test_atomic_write(tmp_path: Path):
+ target_file = tmp_path / "test.json"
+ _atomic_write(target_file, '{"test": 123}')
+
+ assert target_file.exists()
+ assert target_file.read_text(encoding="utf-8") == '{"test": 123}'
+ assert not (tmp_path / "test.json.tmp").exists()
diff --git a/tests/test_integration.py b/tests/test_integration.py
index b96af00..aa9c7c3 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -17,22 +17,22 @@
#020617
#ffffff
#020617
- oklch(0.21 0.006 285.885)
+ #0f172a
#f8fafc
- oklch(0.967 0.001 286.375)
+ #f1f5f9
#0f172a
- oklch(0.967 0.001 286.375)
- oklch(0.552 0.016 285.938)
- oklch(0.967 0.001 286.375)
+ #f1f5f9
+ #64748b
+ #f1f5f9
#0f172a
- oklch(0.577 0.245 27.325)
+ #ef4444
#f8fafc
- oklch(0.92 0.004 286.32)
- oklch(0.92 0.004 286.32)
- oklch(0.705 0.015 286.067)
- 0.25rem
- 0.5rem
- system-ui, sans-serif
+ #e2e8f0
+ #e2e8f0
+ #0f172a
+ 4px
+ 8px
+ Open Sans
#020617
@@ -41,22 +41,22 @@
#f8fafc
#020617
#f8fafc
- oklch(0.985 0 0)
+ #f8fafc
#0f172a
- oklch(0.274 0.006 286.033)
+ #1e293b
#f8fafc
- oklch(0.274 0.006 286.033)
- oklch(0.705 0.015 286.067)
- oklch(0.274 0.006 286.033)
+ #1e293b
+ #94a3b8
+ #1e293b
#f8fafc
- oklch(0.396 0.141 25.723)
+ #7f1d1d
#f8fafc
- oklch(0.274 0.006 286.033)
- oklch(0.274 0.006 286.033)
- oklch(0.442 0.017 285.786)
- 0.25rem
- 0.5rem
- system-ui, sans-serif
+ #1e293b
+ #1e293b
+ #cbd5e1
+ 4px
+ 8px
+ Open Sans
"""
@@ -80,7 +80,7 @@ def test_full_pipeline(self, qapp: QtWidgets.QApplication, full_theme_path: Path
tokens = getTheme()
assert isinstance(tokens, ShadcnThemeTokens)
assert tokens.background == "#ffffff"
- assert "rgb(" in tokens.primary
+ assert tokens.primary == "#0f172a"
assert "px" in tokens.radius
assert tokens.spacing == "4px"
assert qapp.styleSheet() != ""
@@ -93,4 +93,3 @@ def test_qss_output_is_qt_safe(self, qapp: QtWidgets.QApplication, full_theme_pa
qss = _build_theme(tokens)
assert "oklch(" not in qss
assert "rem" not in qss
- assert "rgba(" in qss or "rgb(" in qss
diff --git a/tests/test_parser.py b/tests/test_parser.py
index aa8f6a2..557dac4 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -1,10 +1,9 @@
"""Tests for the QtShadcn XML theme parser."""
-import re
from pathlib import Path
import pytest
-from qtshadcn.common.theme_parser import parse_theme_source, resolve_value
+from qtshadcn.common.theme_parser import parse_theme_source
from qtshadcn.exceptions import ThemeParseError
SAMPLE_XML = """\
@@ -16,7 +15,7 @@
#020617
#ffffff
#020617
- oklch(0.55 0.15 270)
+ #0f172a
#f8fafc
#f1f5f9
#0f172a
@@ -29,9 +28,9 @@
#e2e8f0
#e2e8f0
#0f172a
- 0.25rem
- 0.5rem
- system-ui, sans-serif
+ 4px
+ 8px
+ Open Sans
#020617
@@ -40,7 +39,7 @@
#f8fafc
#020617
#f8fafc
- oklch(0.85 0.05 270)
+ #f8fafc
#0f172a
#1e293b
#f8fafc
@@ -53,9 +52,9 @@
#1e293b
#1e293b
#cbd5e1
- 0.25rem
- 0.5rem
- system-ui, sans-serif
+ 4px
+ 8px
+ Open Sans
"""
@@ -143,79 +142,9 @@ def test_packaged_default_theme_is_complete(self):
assert theme.light.secondary == "#f5f5f5"
assert theme.dark.secondary == "#262626"
- def test_convertible_value_not_defaulted(self, sample_xml: Path):
- """Test that convertible values are not defaulted."""
+ def test_values_are_passed_through(self, sample_xml: Path):
+ """Test that raw HEX and px values are used unchanged."""
theme = parse_theme_source(sample_xml)
assert theme.light.radius == "8px"
assert theme.light.spacing == "4px"
- assert theme.light.primary.startswith("rgb(")
-
-
-class TestResolveValue:
- """Tests for the value resolver."""
-
- def test_oklch_to_rgb(self):
- """Test that oklch is converted to rgb."""
- result = resolve_value("oklch(0.55 0.15 270)")
- assert result.startswith("rgb(")
- match = re.match(r"rgb\((\d+),\s*(\d+),\s*(\d+)\)", result)
- assert match is not None
- r, g, b = map(int, match.groups())
- assert 0 <= r <= 255
- assert 0 <= g <= 255
- assert 0 <= b <= 255
-
- def test_oklch_to_rgb_channel_accuracy(self):
- """Test that oklch is converted to rgb with channel accuracy."""
- # oklch(1 0 0) -> white
- assert resolve_value("oklch(1 0 0)") == "rgb(255, 255, 255)"
- # oklch(0 0 0) -> black
- assert resolve_value("oklch(0 0 0)") == "rgb(0, 0, 0)"
-
- def test_oklch_with_alpha(self):
- """Test that oklch is converted to rgba with alpha."""
- result = resolve_value("oklch(0.55 0.15 270 / 0.5)")
- assert result.startswith("rgba(")
- assert result.endswith(", 0.5)")
-
- def test_oklch_with_percentage_alpha(self):
- """Test that oklch is converted to rgba with percentage alpha."""
- result = resolve_value("oklch(0.55 0.15 270 / 50%)")
- assert result == "rgba(80, 105, 200, 0.5)"
-
- def test_oklch_with_percentage_lightness(self):
- """Test that oklch is converted to rgba with percentage lightness."""
- result = resolve_value("oklch(100% 0 0)")
- assert result == "rgb(255, 255, 255)"
-
- def test_hsl_to_rgb(self):
- """Test that hsl is converted to rgb."""
- result = resolve_value("hsl(0 100% 50%)")
- assert result == "rgb(255, 0, 0)"
-
- def test_hsl_with_alpha(self):
- """Test that hsl is converted to rgba with alpha."""
- result = resolve_value("hsl(0 100% 50% / 0.5)")
- assert result == "rgba(255, 0, 0, 0.5)"
-
- def test_rem_to_px(self):
- """Test that rem is converted to px."""
- assert resolve_value("0.5rem") == "8px"
- assert resolve_value("1rem") == "16px"
- assert resolve_value("1.5rem") == "24px"
-
- def test_rgb_passthrough(self):
- """Test that rgb is passed through."""
- assert resolve_value("rgb(255, 0, 0)") == "rgb(255, 0, 0)"
-
- def test_rgba_passthrough(self):
- """Test that rgba is passed through."""
- assert resolve_value("rgba(255, 0, 0, 0.5)") == "rgba(255, 0, 0, 0.5)"
-
- def test_hex_passthrough(self):
- """Test that hex is passed through."""
- assert resolve_value("#0f172a") == "#0f172a"
-
- def test_px_passthrough(self):
- """Test that px is passed through."""
- assert resolve_value("16px") == "16px"
+ assert theme.light.primary == "#0f172a"
diff --git a/tests/test_renderer.py b/tests/test_renderer.py
index 08252cd..69b9e57 100644
--- a/tests/test_renderer.py
+++ b/tests/test_renderer.py
@@ -614,7 +614,7 @@ def test_tab_widget_default_pane_is_rendered(self):
"background: transparent;",
"color: #64748b;",
"border: 1px solid transparent;",
- "border-radius: 6.4px;",
+ "border-radius: 6px;",
"margin: 2.8px;",
"padding: 2px 12px;",
"min-height: 28px;",
diff --git a/tests/test_stylesheet.py b/tests/test_stylesheet.py
index 024b24b..7b61aaf 100644
--- a/tests/test_stylesheet.py
+++ b/tests/test_stylesheet.py
@@ -17,6 +17,10 @@
toggleThemeMode,
)
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
def _tokens(**overrides):
values = {
@@ -41,12 +45,17 @@ def _tokens(**overrides):
"ring": "#0f172a",
"spacing": "4px",
"radius": "8px",
- "font_family": "system-ui, sans-serif",
+ "font_family": "Open Sans",
}
values.update(overrides)
return values
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
@pytest.fixture
def sample_xml(tmp_path):
path = tmp_path / "theme.xml"
@@ -75,7 +84,7 @@ def sample_xml(tmp_path):
#0f172a
4px
8px
- system-ui, sans-serif
+ Open Sans
#020617
@@ -99,7 +108,7 @@ def sample_xml(tmp_path):
#cbd5e1
4px
8px
- system-ui, sans-serif
+ Open Sans
""",
@@ -111,7 +120,7 @@ def sample_xml(tmp_path):
@pytest.fixture
def sample_json(tmp_path):
path = tmp_path / "theme.json"
- data = {"version": 1, "light": _tokens(), "dark": _tokens(background="#000000")}
+ data = {"light": _tokens(), "dark": _tokens(background="#000000")}
path.write_text(json.dumps(data), encoding="utf-8")
return path
@@ -124,6 +133,11 @@ def _reset_settings(tmp_path):
qsettings.reset_for_test()
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
class TestThemeMode:
def test_set_theme_mode(self):
setThemeMode("dark")
@@ -138,13 +152,11 @@ def test_invalid_theme_mode_raises(self):
setThemeMode("invalid")
def test_toggle_theme_mode(self):
- setThemeMode(ThemeMode.AUTO)
- toggleThemeMode()
- assert themeMode() == ThemeMode.LIGHT
+ setThemeMode(ThemeMode.LIGHT)
toggleThemeMode()
assert themeMode() == ThemeMode.DARK
toggleThemeMode()
- assert themeMode() == ThemeMode.AUTO
+ assert themeMode() == ThemeMode.LIGHT
def test_is_dark_theme_resolves_dark(self):
setThemeMode(ThemeMode.DARK)
diff --git a/tests/test_theme_watcher.py b/tests/test_theme_watcher.py
index 9312ac3..d75be50 100644
--- a/tests/test_theme_watcher.py
+++ b/tests/test_theme_watcher.py
@@ -16,7 +16,6 @@ def _reset_settings():
def _theme_sequence(*values):
- """Return a callable that yields ``values`` and then repeats the last one."""
state = {"index": 0}
def theme():
@@ -29,22 +28,12 @@ def theme():
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",
- ),
+ with patch(
+ "qtshadcn.common.theme_watcher.darkdetect.theme",
+ return_value="Light",
):
listener.start()
listener.wait(1000)
@@ -53,15 +42,9 @@ def test_manual_start_stop(self):
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",
- ),
+ with patch(
+ "qtshadcn.common.theme_watcher.darkdetect.theme",
+ return_value="Light",
):
listener.start()
listener.wait(1000)
@@ -75,60 +58,27 @@ def test_signal_emitted_on_theme_change(self, qtbot):
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"),
- ),
+ with patch(
+ "qtshadcn.common.theme_watcher.darkdetect.theme",
+ side_effect=_theme_sequence("Light", "Dark"),
):
listener.start()
- qtbot.wait(1000)
+ qtbot.wait(500)
listener.stop()
assert "Dark" in collected
- def test_polling_fallback_used_on_non_windows(self, qtbot):
+ def test_polling_detects_theme_change(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"),
- ),
+ with patch(
+ "qtshadcn.common.theme_watcher.darkdetect.theme",
+ side_effect=_theme_sequence("Light", "Light", "Dark"),
):
listener.start()
- qtbot.wait(1000)
+ qtbot.wait(500)
listener.stop()
assert "Dark" in collected
@@ -149,7 +99,7 @@ def on_change(theme):
received.append(theme)
listener.themeChanged.connect(on_change)
- listener._on_theme_changed("Dark")
+ listener.themeChanged.emit("Dark")
assert received == ["Dark"]
assert themeMode() == ThemeMode.DARK
@@ -168,7 +118,7 @@ def on_change(theme):
received.append(theme)
listener.themeChanged.connect(on_change)
- listener._on_theme_changed("Dark")
+ listener.themeChanged.emit("Dark")
assert received == ["Dark"]
assert themeMode() == ThemeMode.LIGHT
diff --git a/tests/test_tokens.py b/tests/test_tokens.py
index 9ea393a..cceeab1 100644
--- a/tests/test_tokens.py
+++ b/tests/test_tokens.py
@@ -4,59 +4,59 @@
from qtshadcn.tokens import colors, radius, scale
-def test_color_alpha_renders_rgba_from_hex():
+def test_color_with_alpha_renders_rgba_from_hex():
"""Test that CSS slash-alpha equivalents become static rgba values."""
- assert colors.alpha("#0f172a", 0.8) == "rgba(15, 23, 42, 0.8)"
- assert colors.alpha("#fff", 0.5) == "rgba(255, 255, 255, 0.5)"
+ assert colors.withAlpha("#0f172a", 0.8) == "rgba(15, 23, 42, 0.8)"
+ assert colors.withAlpha("#fff", 0.5) == "rgba(255, 255, 255, 0.5)"
-def test_color_mix_renders_static_rgb():
+def test_color_blend_colors_renders_static_rgb():
"""Test that color-mix equivalents become static rgb values."""
- assert colors.mix("#f1f5f9", "#020617", 0.05) == "rgb(229, 233, 238)"
+ assert colors.blendColors("#f1f5f9", "#020617", 0.05) == "rgb(229, 233, 238)"
-def test_radius_min_px_uses_static_cap():
+def test_radius_clamp_radius_uses_static_cap():
"""Test that CSS min() radius equivalents become static pixel values."""
- assert radius.min_px("8px", "md", "10px") == "6.4px"
- assert radius.min_px("20px", "md", "10px") == "10px"
+ assert radius.clampRadius("8px", "md", "10px") == "6px"
+ assert radius.clampRadius("20px", "md", "10px") == "10px"
-def test_radius_class_px_maps_tailwind_names():
+def test_radius_resolve_radius_maps_tailwind_names():
"""Test Tailwind rounded-* names become static QSS pixel values."""
- assert radius.class_px("10px", "none") == "0px"
- assert radius.class_px("10px", "xs") == "2px"
- assert radius.class_px("10px", "sm") == "6px"
- assert radius.class_px("10px", "md") == "8px"
- assert radius.class_px("10px", "lg") == "10px"
- assert radius.class_px("10px", "xl") == "14px"
- assert radius.class_px("10px", "2xl") == "18px"
- assert radius.class_px("10px", "3xl") == "22px"
- assert radius.class_px("10px", "4xl") == "26px"
- assert radius.class_px("10px", "full") == "9999px"
+ assert radius.resolveRadius("10px", "none") == "0px"
+ assert radius.resolveRadius("10px", "xs") == "2px"
+ assert radius.resolveRadius("10px", "sm") == "6px"
+ assert radius.resolveRadius("10px", "md") == "8px"
+ assert radius.resolveRadius("10px", "lg") == "10px"
+ assert radius.resolveRadius("10px", "xl") == "14px"
+ assert radius.resolveRadius("10px", "2xl") == "16px"
+ assert radius.resolveRadius("10px", "3xl") == "24px"
+ assert radius.resolveRadius("10px", "4xl") == "32px"
+ assert radius.resolveRadius("10px", "full") == "9999px"
def test_radius_arithmetic_helpers_render_static_pixels():
"""Test radius arithmetic helpers avoid CSS functions in QSS output."""
- assert radius.min_px("20px", "md", "10px") == "10px"
- assert radius.min_px("8px", "md", "10px") == "6.4px"
- assert radius.add_px("8px", "md", "2px") == "8.4px"
- assert radius.sub_px("8px", "md", "2px") == "4.4px"
- assert radius.sub_px("8px", "none", "2px") == "0px"
+ assert radius.clampRadius("20px", "md", "10px") == "10px"
+ assert radius.clampRadius("8px", "md", "10px") == "6px"
+ assert radius.expandRadius("8px", "md", "2px") == "8px"
+ assert radius.shrinkRadius("8px", "md", "2px") == "4px"
+ assert radius.shrinkRadius("8px", "none", "2px") == "0px"
def test_radius_helpers_reject_non_px_values():
"""Test radius helpers fail before emitting unsupported QSS units."""
with pytest.raises(ValueError, match="Expected a pixel value"):
- radius.class_px("0.5rem", "lg")
+ radius.resolveRadius("0.5em", "lg")
-def test_spacing_px_matches_scale_px():
+def test_resolve_spacing_matches_scale_px():
"""Test spacing multiples keep the existing scale behavior."""
- assert scale.spacing_px("4px", 1.5) == "6px"
+ assert scale.resolveSpacing("4px", 1.5) == "6px"
-def test_spacing_int_returns_integer_pixels():
+def test_to_spacing_int_returns_integer_pixels():
"""Test spacing multiples can be returned as integer pixel values."""
- assert scale.spacing_int("4px", 1) == 4
- assert scale.spacing_int("4px", 3) == 12
- assert scale.spacing_int("6px", 1.5) == 9
+ assert scale.toSpacingInt("4px", 1) == 4
+ assert scale.toSpacingInt("4px", 3) == 12
+ assert scale.toSpacingInt("6px", 1.5) == 9