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