From 401034c09d94bd8b2e0230057d239273af4f1add Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 22:59:49 -0700 Subject: [PATCH 01/12] fix(color): reject non-ASCII hex input instead of panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_hex sliced the input by byte index after checking only the byte length. A 7-byte string containing a multibyte character (for example "#aé000") passed the length check and then panicked at a char boundary inside the slice. Because palette values flow straight into from_hex, a malformed user theme file could crash any app using the discovery feature instead of surfacing an OpalineError. Reject non-ASCII input up front as InvalidHex so the byte-index slices below are always valid. Regression tests cover from_hex directly and the loader path that reads palette values. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- src/color.rs | 5 +++++ tests/color_tests.rs | 20 ++++++++++++++++++++ tests/loader_tests.rs | 9 +++++++++ 3 files changed, 34 insertions(+) diff --git a/src/color.rs b/src/color.rs index c771e69..3545e8e 100644 --- a/src/color.rs +++ b/src/color.rs @@ -48,6 +48,11 @@ impl OpalineColor { /// Parse a hex color string like `#rrggbb`. pub fn from_hex(hex: &str) -> Result { let hex = hex.trim(); + // The byte-index slices below are only valid on ASCII input; a + // multibyte character would otherwise panic at a char boundary. + if !hex.is_ascii() { + return Err(ColorParseError::InvalidHex(hex.to_string())); + } if hex.len() != 7 || !hex.starts_with('#') { return Err(ColorParseError::InvalidLength(hex.len())); } diff --git a/tests/color_tests.rs b/tests/color_tests.rs index f22e216..68de995 100644 --- a/tests/color_tests.rs +++ b/tests/color_tests.rs @@ -33,6 +33,26 @@ fn from_hex_white() { assert_eq!(c, OpalineColor::new(255, 255, 255)); } +#[test] +fn from_hex_non_ascii_returns_error_instead_of_panicking() { + // 7 bytes but only 6 chars: byte-index slicing would land mid-char. + let err = OpalineColor::from_hex("#a\u{e9}000").expect_err("non-ascii is rejected"); + assert!(matches!(err, ColorParseError::InvalidHex(_))); + + // Non-ASCII at every position, including ones that keep the byte length at 7. + for input in [ + "#\u{e9}\u{e9}000", + "#00\u{e9}00", + "#0000\u{e9}", + "\u{ff03}ff0000", + ] { + assert!( + OpalineColor::from_hex(input).is_err(), + "{input:?} should not parse" + ); + } +} + #[test] fn from_hex_trims_whitespace() { let c = OpalineColor::from_hex(" #ff0000 ").expect("valid hex with whitespace"); diff --git a/tests/loader_tests.rs b/tests/loader_tests.rs index 2bba670..3eddb01 100644 --- a/tests/loader_tests.rs +++ b/tests/loader_tests.rs @@ -54,6 +54,15 @@ fn loaded_theme_resolves_gradients() { assert_eq!(theme.gradient("primary", 1.0), OpalineColor::new(0, 0, 255)); } +#[test] +fn non_ascii_palette_hex_returns_invalid_color_error() { + // A user theme file must never crash the loader: this 7-byte value + // used to panic inside hex parsing at a char boundary. + let toml = "[meta]\nname = \"Bad\"\n[palette]\nbad = \"#a\u{e9}000\"\n"; + let err = loader::load_from_str(toml, None).expect_err("non-ascii hex is rejected"); + assert!(matches!(err, OpalineError::InvalidColor { ref token, .. } if token == "bad")); +} + #[test] fn missing_token_returns_fallback() { let theme = loader::load_from_str(MINIMAL_TOML, None).expect("valid TOML"); From 2a471ddd1993988f2f0907038652391f9f022d85 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:00:57 -0700 Subject: [PATCH 02/12] fix(themes): map catppuccin bg.base to base across all flavors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three dark Catppuccin flavors mapped bg.base to crust and bg.panel to base, while Latte already mapped bg.base to base and bg.panel to mantle. Upstream Catppuccin defines base as the main background pane and mantle/crust as the secondary panes, so an app that paints its canvas with bg.base showed crust (#11111b for Mocha) instead of the color every Catppuccin terminal already uses (#1e1e2e). Align Mocha, Macchiato, and Frappé with Latte and with upstream: bg.base = base, bg.panel = mantle, bg.code = crust. The surface ladder (highlight, elevated, active, selection) is unchanged. A fidelity test pins the mapping for all four flavors so it cannot drift again. Reported in GitHub issue #2. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- src/builtins/catppuccin_frappe.toml | 6 +++--- src/builtins/catppuccin_macchiato.toml | 6 +++--- src/builtins/catppuccin_mocha.toml | 6 +++--- tests/builtins_tests.rs | 21 +++++++++++++++++++++ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/builtins/catppuccin_frappe.toml b/src/builtins/catppuccin_frappe.toml index d3480d5..95bd277 100644 --- a/src/builtins/catppuccin_frappe.toml +++ b/src/builtins/catppuccin_frappe.toml @@ -47,9 +47,9 @@ lavender = "#babbf1" "text.muted" = "subtext0" "text.dim" = "overlay1" -"bg.base" = "crust" -"bg.panel" = "base" -"bg.code" = "mantle" +"bg.base" = "base" +"bg.panel" = "mantle" +"bg.code" = "crust" "bg.highlight" = "surface0" "bg.elevated" = "surface1" "bg.active" = "surface2" diff --git a/src/builtins/catppuccin_macchiato.toml b/src/builtins/catppuccin_macchiato.toml index 3eb2a4e..8fe3863 100644 --- a/src/builtins/catppuccin_macchiato.toml +++ b/src/builtins/catppuccin_macchiato.toml @@ -47,9 +47,9 @@ lavender = "#b7bdf8" "text.muted" = "subtext0" "text.dim" = "overlay1" -"bg.base" = "crust" -"bg.panel" = "base" -"bg.code" = "mantle" +"bg.base" = "base" +"bg.panel" = "mantle" +"bg.code" = "crust" "bg.highlight" = "surface0" "bg.elevated" = "surface1" "bg.active" = "surface2" diff --git a/src/builtins/catppuccin_mocha.toml b/src/builtins/catppuccin_mocha.toml index e6ec004..38d915d 100644 --- a/src/builtins/catppuccin_mocha.toml +++ b/src/builtins/catppuccin_mocha.toml @@ -47,9 +47,9 @@ lavender = "#b4befe" "text.muted" = "subtext0" "text.dim" = "overlay1" -"bg.base" = "crust" -"bg.panel" = "base" -"bg.code" = "mantle" +"bg.base" = "base" +"bg.panel" = "mantle" +"bg.code" = "crust" "bg.highlight" = "surface0" "bg.elevated" = "surface1" "bg.active" = "surface2" diff --git a/tests/builtins_tests.rs b/tests/builtins_tests.rs index 0120b52..7a74221 100644 --- a/tests/builtins_tests.rs +++ b/tests/builtins_tests.rs @@ -240,6 +240,27 @@ fn all_builtins_have_required_gradients() { } } +// ── Upstream fidelity ──────────────────────────────────────────────────── + +/// Catppuccin defines `base` as the main background and `mantle` as the +/// secondary-pane color. Every flavor must map the contract the same way +/// so an app painted with `bg.base` matches a Catppuccin terminal. +#[test] +fn catppuccin_flavors_map_base_to_main_background() { + let expected = [ + ("catppuccin-mocha", "#1e1e2e", "#181825", "#11111b"), + ("catppuccin-macchiato", "#24273a", "#1e2030", "#181926"), + ("catppuccin-frappe", "#303446", "#292c3c", "#232634"), + ("catppuccin-latte", "#eff1f5", "#e6e9ef", "#dce0e8"), + ]; + for (id, base, mantle, crust) in expected { + let theme = builtins::load_by_name(id).expect("loads"); + assert_eq!(theme.color("bg.base").to_hex(), base, "{id} bg.base"); + assert_eq!(theme.color("bg.panel").to_hex(), mantle, "{id} bg.panel"); + assert_eq!(theme.color("bg.code").to_hex(), crust, "{id} bg.code"); + } +} + // ── Variant correctness ────────────────────────────────────────────────── #[test] From 09967f1b60d7241676738d0b512e858d92837e9b Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:02:21 -0700 Subject: [PATCH 03/12] feat(names): promote bg.elevated, bg.active, cursor_line to the contract All 39 builtin themes already define the bg.elevated and bg.active tokens and the cursor_line style, but none of them had a constant in opaline::names and none were in the documented or tested contract. Consumers had to reach for raw strings and could not rely on the tokens existing. Add BG_ELEVATED, BG_ACTIVE, and CURSOR_LINE constants, extend the contract to 28 tokens and 14 styles, and rewrite the contract test lists in terms of the names constants so the two can never drift again. A size assertion pins the numbers the docs quote. The token guide now describes the bg.* family as a layering ladder (bg.base is the canvas, bg.panel the secondary pane, and the rest step toward the text color) and explains why some palettes recess panels while others raise them. It also corrects a stale claim that missing tokens fall back to magenta; the fallback is neutral gray. Reported in GitHub issue #2. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- AGENTS.md | 4 +- CONTRIBUTING.md | 2 +- README.md | 10 ++-- docs/getting-started/index.md | 2 +- docs/guide/custom-themes.md | 5 +- docs/guide/styles.md | 3 +- docs/guide/tokens.md | 24 +++++---- docs/reference/api.md | 2 +- docs/reference/tokens.md | 9 ++-- src/names.rs | 13 ++++- tests/builtins_tests.rs | 99 +++++++++++++++++++---------------- 11 files changed, 102 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e94df0a..295a9af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,11 +117,11 @@ Solarized (Dark, Light), One (Dark, Light) ## Token Contract -Every builtin theme must define 26 core semantic tokens across these namespaces: +Every builtin theme must define 28 core semantic tokens across these namespaces: `text.*`, `bg.*`, `accent.*`, `success/error/warning/info`, `border.*`, `code.*` -Plus 13 required styles, 5 required gradients — enforced by contract tests. +Plus 14 required styles, 5 required gradients — enforced by contract tests. App-specific semantics (git status, diff, mode indicators) are derived by consuming apps via `register_default_token()`, not baked into the core. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a77503..f48671c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ The easiest way to contribute — drop a `.toml` file in `src/builtins/`: 1. Copy an existing theme as a starting point 2. Fill in `[meta]`, `[palette]`, `[tokens]`, `[styles]`, `[gradients]` -3. Run `cargo test --all-features` — the contract tests enforce 26 tokens, 13 styles, 5 gradients +3. Run `cargo test --all-features` — the contract tests enforce 28 tokens, 14 styles, 5 gradients 4. Open a PR Use underscores in filenames (e.g., `my_theme.toml` becomes id `my-theme`). Themes are auto-discovered at compile time via `build.rs`. diff --git a/README.md b/README.md index 252612e..e0824d4 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,14 @@ Opaline ships adapters for **ratatui**, **egui**, **iced**, **crossterm**, **owo TOML file → ThemeFile (serde) → Resolver (palette → tokens → styles → gradients) → Theme ``` -Opaline ships with **39 professionally crafted themes** spanning 17 colorscheme families, all enforced by a strict contract test suite that validates 26 core semantic tokens, 13 required styles, and 5 gradients per theme. +Opaline ships with **39 professionally crafted themes** spanning 17 colorscheme families, all enforced by a strict contract test suite that validates 28 core semantic tokens, 14 required styles, and 5 gradients per theme. ## ✦ Features | Feature | Description | | --- | --- | | 🎨 **39 Builtin Themes** | SilkCircuit, Catppuccin, GitHub, Monokai Pro, Ayu, Night Owl, Flexoki, Palenight, Dracula, Nord, Rose Pine, Gruvbox, Solarized, Tokyo Night, Kanagawa, Everforest, One Dark/Light | -| 🔗 **Semantic Tokens** | 26 core tokens across generic `text.*`, `bg.*`, `accent.*`, `border.*`, and `code.*` namespaces | +| 🔗 **Semantic Tokens** | 28 core tokens across generic `text.*`, `bg.*`, `accent.*`, `border.*`, and `code.*` namespaces | | 🌊 **Multi-Stop Gradients** | Smooth color interpolation with `gradient_bar()`, `gradient_text_line()`, and `gradient_spans()` | | 🖥️ **Deep Ratatui Integration** | `From` impls, `Styled` trait, inherent `span()`, `line()`, `text()`, `gradient_text()` on `Theme` | | 🎮 **egui Integration** | `Color32` conversion, full `Visuals` generation from theme tokens | @@ -148,7 +148,7 @@ Browse all 39 themes, see every style and gradient rendered in real-time. | **Solarized** | Dark, Light | Precision colors for machines and people | | **One** | Dark, Light | Atom's iconic syntax palette | -Every theme is contract-tested: 26 core semantic tokens, 13 required styles, 5 required gradients. +Every theme is contract-tested: 28 core semantic tokens, 14 required styles, 5 required gradients. ## 🔮 Usage @@ -244,11 +244,11 @@ purple = "#bb9af7" "bg.base" = "bg" "bg.selection" = "bg" "accent.primary" = "blue" -# ... 26 required core tokens across text.*, bg.*, accent.*, border.*, code.*, etc. +# ... 28 required core tokens across text.*, bg.*, accent.*, border.*, code.*, etc. [styles] keyword = { fg = "accent.primary", bold = true } -# ... 13 required core styles +# ... 14 required core styles [gradients] primary = ["blue", "purple"] diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index deff5df..bdb1b66 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -53,7 +53,7 @@ This separation means palette swaps propagate through the entire theme automatic | Feature | Description | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **39 builtin themes** | SilkCircuit, Catppuccin, GitHub, Monokai Pro, Ayu, Night Owl, Flexoki, Palenight, Rose Pine, Everforest, Tokyo Night, Kanagawa, Dracula, Nord, Gruvbox, Solarized, One | -| **Token system** | 26 core semantic tokens across generic namespaces | +| **Token system** | 28 core semantic tokens across generic namespaces | | **Gradients** | Multi-stop color interpolation with `at(t)` and `generate(n)` | | **7 adapters** | ratatui, egui, crossterm, owo-colors, syntect, CSS, colored | | **ThemeBuilder** | Programmatic theme construction without TOML | diff --git a/docs/guide/custom-themes.md b/docs/guide/custom-themes.md index 06b2437..00430c2 100644 --- a/docs/guide/custom-themes.md +++ b/docs/guide/custom-themes.md @@ -38,6 +38,8 @@ orange = "#ffb86c" "bg.panel" = "bg" "bg.code" = "bg" "bg.highlight" = "bg" +"bg.elevated" = "bg" +"bg.active" = "bg" "bg.selection" = "bg" "accent.primary" = "accent" @@ -64,6 +66,7 @@ info = "blue" [styles] keyword = { fg = "accent.primary", bold = true } line_number = { fg = "code.line_number" } +cursor_line = { bg = "bg.highlight" } selected = { fg = "accent.secondary", bg = "bg.highlight" } active_selected = { fg = "accent.primary", bg = "bg.highlight", bold = true } focused_border = { fg = "border.focused" } @@ -123,7 +126,7 @@ The strict resolver catches issues at load time: - **Circular reference**: tokens form a cycle (`a → b → a`) - **Invalid hex**: a palette value isn't a valid hex color -If your theme loads without error, it's valid. For builtin-level quality, ensure it defines all required tokens, 13 required styles, and 5 required gradients. +If your theme loads without error, it's valid. For builtin-level quality, ensure it defines all 28 required tokens, 14 required styles, and 5 required gradients. ## Tips diff --git a/docs/guide/styles.md b/docs/guide/styles.md index ac38e2c..9d675b1 100644 --- a/docs/guide/styles.md +++ b/docs/guide/styles.md @@ -54,12 +54,13 @@ inline_code = { fg = "success", bg = "bg.code" } ## Required Styles -Every builtin theme must define these 13 styles: +Every builtin theme must define these 14 styles: | Style | Purpose | | ------------------ | ---------------------- | | `keyword` | Language keywords | | `line_number` | Code line numbers | +| `cursor_line` | Line under the cursor | | `selected` | Selected item | | `active_selected` | Active + selected item | | `focused_border` | Focused panel border | diff --git a/docs/guide/tokens.md b/docs/guide/tokens.md index 021cd7d..375ba9a 100644 --- a/docs/guide/tokens.md +++ b/docs/guide/tokens.md @@ -4,7 +4,7 @@ Tokens are the **semantic layer** between raw palette colors and composed styles ## Token Namespaces -Opaline's core token contract defines 26 tokens across 6 namespaces: +Opaline's core token contract defines 28 tokens across 6 namespaces: ### Text @@ -17,13 +17,17 @@ Opaline's core token contract defines 26 tokens across 6 namespaces: ### Background -| Token | Purpose | -| -------------- | --------------------------- | -| `bg.base` | Main background | -| `bg.panel` | Panel/sidebar background | -| `bg.code` | Code block background | -| `bg.highlight` | Highlighted line background | -| `bg.selection` | Selection background | +| Token | Purpose | +| -------------- | ---------------------------------------------------------- | +| `bg.base` | Main canvas: the color an app paints the whole screen with | +| `bg.highlight` | Highlighted or hovered line background | +| `bg.panel` | Sidebars, secondary panes, status areas | +| `bg.code` | Code block background | +| `bg.elevated` | Raised surfaces: popups, modals, dropdowns | +| `bg.active` | Active or pressed element background | +| `bg.selection` | Selection background | + +The `bg.*` tokens form a layering ladder. `bg.base` is the surface everything else sits on, and `bg.highlight`, `bg.elevated`, and `bg.active` step progressively toward the text color so each layer reads as closer to the viewer. `bg.panel` is one step away from `bg.base` in whichever direction the source palette prefers: SilkCircuit and Rose Pine raise panels slightly lighter, while Catppuccin and Kanagawa recess them slightly darker, matching how those palettes treat sidebars upstream. ### Accent @@ -67,7 +71,7 @@ Opaline's core token contract defines 26 tokens across 6 namespaces: ```rust let theme = opaline::Theme::default(); -// Get a resolved color (falls back to magenta if missing) +// Get a resolved color (falls back to neutral gray if missing) let color = theme.color("accent.primary"); // Strict lookup: None if missing @@ -95,7 +99,7 @@ let kw = theme.style(styles::KEYWORD); let has_aurora = theme.has_gradient(gradients::AURORA); ``` -All 26 required tokens, 13 required styles, and 5 required gradients have corresponding constants. +All 28 required tokens, 14 required styles, and 5 required gradients have corresponding constants. ## App-Specific Tokens diff --git a/docs/reference/api.md b/docs/reference/api.md index 80a110a..29ab718 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -162,7 +162,7 @@ theme.style(styles::KEYWORD) // OpalineStyle theme.has_gradient(gradients::AURORA) // bool ``` -Modules: `names::tokens` (26 required constants), `names::styles` (13 required constants), `names::gradients` (5 constants). +Modules: `names::tokens` (28 required constants), `names::styles` (14 required constants), `names::gradients` (5 constants). ## Ratatui Integration diff --git a/docs/reference/tokens.md b/docs/reference/tokens.md index ddbcfcd..59ba1be 100644 --- a/docs/reference/tokens.md +++ b/docs/reference/tokens.md @@ -2,7 +2,7 @@ Every builtin theme must define a minimum set of semantic tokens, styles, and gradients. This contract ensures that consuming applications can rely on these names existing in any theme. -## Required Tokens (26) +## Required Tokens (28) These tokens must be present in every builtin theme: @@ -15,13 +15,15 @@ text.muted text.dim ``` -### Background (5) +### Background (7) ``` bg.base bg.panel bg.code bg.highlight +bg.elevated +bg.active bg.selection ``` @@ -62,11 +64,12 @@ code.type code.line_number ``` -## Required Styles (13) +## Required Styles (14) ``` keyword line_number +cursor_line selected active_selected focused_border diff --git a/src/names.rs b/src/names.rs index 79086bd..fa04a3a 100644 --- a/src/names.rs +++ b/src/names.rs @@ -11,7 +11,13 @@ //! let kw = theme.style(styles::KEYWORD); //! ``` -/// Semantic color token names (26 required). +/// Semantic color token names (28 required). +/// +/// The `bg.*` family is a layering ladder. `BG_BASE` is the canvas an app +/// paints first; `BG_PANEL` is for sidebars and secondary panes; the rest +/// (`BG_HIGHLIGHT`, `BG_ELEVATED`, `BG_ACTIVE`, `BG_SELECTION`) step +/// progressively toward the text color for hover, popups, and pressed +/// states. pub mod tokens { pub const TEXT_PRIMARY: &str = "text.primary"; pub const TEXT_SECONDARY: &str = "text.secondary"; @@ -22,6 +28,8 @@ pub mod tokens { pub const BG_PANEL: &str = "bg.panel"; pub const BG_CODE: &str = "bg.code"; pub const BG_HIGHLIGHT: &str = "bg.highlight"; + pub const BG_ELEVATED: &str = "bg.elevated"; + pub const BG_ACTIVE: &str = "bg.active"; pub const BG_SELECTION: &str = "bg.selection"; pub const ACCENT_PRIMARY: &str = "accent.primary"; @@ -45,10 +53,11 @@ pub mod tokens { pub const CODE_LINE_NUMBER: &str = "code.line_number"; } -/// Named style constants (13 required). +/// Named style constants (14 required). pub mod styles { pub const KEYWORD: &str = "keyword"; pub const LINE_NUMBER: &str = "line_number"; + pub const CURSOR_LINE: &str = "cursor_line"; pub const SELECTED: &str = "selected"; pub const ACTIVE_SELECTED: &str = "active_selected"; pub const FOCUSED_BORDER: &str = "focused_border"; diff --git a/tests/builtins_tests.rs b/tests/builtins_tests.rs index 7a74221..8173e8a 100644 --- a/tests/builtins_tests.rs +++ b/tests/builtins_tests.rs @@ -1,5 +1,6 @@ use opaline::OpalineColor; use opaline::builtins; +use opaline::names::{gradients, styles, tokens}; use opaline::schema::ThemeVariant; use pretty_assertions::assert_eq; use std::fs; @@ -93,34 +94,43 @@ fn builtin_count_is_39() { // ── Token contract: every builtin has the required semantic tokens ──────── const REQUIRED_TOKENS: &[&str] = &[ - "text.primary", - "text.secondary", - "text.muted", - "text.dim", - "bg.base", - "bg.panel", - "bg.code", - "bg.highlight", - "bg.selection", - "accent.primary", - "accent.secondary", - "accent.tertiary", - "accent.deep", - "success", - "error", - "warning", - "info", - "border.focused", - "border.unfocused", - "code.keyword", - "code.function", - "code.string", - "code.number", - "code.comment", - "code.type", - "code.line_number", + tokens::TEXT_PRIMARY, + tokens::TEXT_SECONDARY, + tokens::TEXT_MUTED, + tokens::TEXT_DIM, + tokens::BG_BASE, + tokens::BG_PANEL, + tokens::BG_CODE, + tokens::BG_HIGHLIGHT, + tokens::BG_ELEVATED, + tokens::BG_ACTIVE, + tokens::BG_SELECTION, + tokens::ACCENT_PRIMARY, + tokens::ACCENT_SECONDARY, + tokens::ACCENT_TERTIARY, + tokens::ACCENT_DEEP, + tokens::SUCCESS, + tokens::ERROR, + tokens::WARNING, + tokens::INFO, + tokens::BORDER_FOCUSED, + tokens::BORDER_UNFOCUSED, + tokens::CODE_KEYWORD, + tokens::CODE_FUNCTION, + tokens::CODE_STRING, + tokens::CODE_NUMBER, + tokens::CODE_COMMENT, + tokens::CODE_TYPE, + tokens::CODE_LINE_NUMBER, ]; +#[test] +fn contract_sizes_match_documentation() { + assert_eq!(REQUIRED_TOKENS.len(), 28); + assert_eq!(REQUIRED_STYLES.len(), 14); + assert_eq!(REQUIRED_GRADIENTS.len(), 5); +} + #[test] fn all_builtins_have_required_tokens() { for &(id, _) in builtins::builtin_names() { @@ -135,19 +145,20 @@ fn all_builtins_have_required_tokens() { } const REQUIRED_STYLES: &[&str] = &[ - "keyword", - "line_number", - "selected", - "active_selected", - "focused_border", - "unfocused_border", - "success_style", - "error_style", - "warning_style", - "info_style", - "dimmed", - "muted", - "inline_code", + styles::KEYWORD, + styles::LINE_NUMBER, + styles::CURSOR_LINE, + styles::SELECTED, + styles::ACTIVE_SELECTED, + styles::FOCUSED_BORDER, + styles::UNFOCUSED_BORDER, + styles::SUCCESS_STYLE, + styles::ERROR_STYLE, + styles::WARNING_STYLE, + styles::INFO_STYLE, + styles::DIMMED, + styles::MUTED, + styles::INLINE_CODE, ]; const FORBIDDEN_LEGACY_TOKENS: &[&str] = &[ @@ -220,11 +231,11 @@ fn builtins_do_not_embed_legacy_app_tokens_or_styles() { } const REQUIRED_GRADIENTS: &[&str] = &[ - "primary", - "warm", - "success_gradient", - "error_gradient", - "aurora", + gradients::PRIMARY, + gradients::WARM, + gradients::SUCCESS_GRADIENT, + gradients::ERROR_GRADIENT, + gradients::AURORA, ]; #[test] From 000ccd79354a4e7009ba0cfacafcabb72f4b001c Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:02:21 -0700 Subject: [PATCH 04/12] fix(egui): pin Stroke widths to f32 literals Stroke::new takes impl Into, and an unsuffixed float literal now triggers the "falling back to f32" future-incompatibility lint on current stable (rust-lang/rust#154024). Twelve call sites produced twelve warnings on every clippy run and will become hard errors in a future release. Suffix the literals so inference has nothing to guess. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- src/adapters/egui.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/adapters/egui.rs b/src/adapters/egui.rs index 84e1748..dbcd4a0 100644 --- a/src/adapters/egui.rs +++ b/src/adapters/egui.rs @@ -83,13 +83,13 @@ pub fn to_egui_visuals(theme: &Theme) -> Visuals { v.hyperlink_color = accent; v.warn_fg_color = theme.color("warning").into(); v.error_fg_color = theme.color("error").into(); - v.window_stroke = Stroke::new(1.0, border_unfocused); + v.window_stroke = Stroke::new(1.0_f32, border_unfocused); // ── Selection ──────────────────────────────────────────────────────── v.selection = Selection { bg_fill: bg_selection, - stroke: Stroke::new(1.0, accent), + stroke: Stroke::new(1.0_f32, accent), }; // ── Widgets ────────────────────────────────────────────────────────── @@ -98,36 +98,36 @@ pub fn to_egui_visuals(theme: &Theme) -> Visuals { noninteractive: WidgetVisuals { bg_fill: bg_base, weak_bg_fill: bg_base, - bg_stroke: Stroke::new(1.0, border_unfocused), - fg_stroke: Stroke::new(1.0, text_muted), + bg_stroke: Stroke::new(1.0_f32, border_unfocused), + fg_stroke: Stroke::new(1.0_f32, text_muted), ..v.widgets.noninteractive }, inactive: WidgetVisuals { bg_fill: bg_panel, weak_bg_fill: bg_panel, - bg_stroke: Stroke::new(1.0, border_unfocused), - fg_stroke: Stroke::new(1.0, text_secondary), + bg_stroke: Stroke::new(1.0_f32, border_unfocused), + fg_stroke: Stroke::new(1.0_f32, text_secondary), ..v.widgets.inactive }, hovered: WidgetVisuals { bg_fill: bg_highlight, weak_bg_fill: bg_highlight, - bg_stroke: Stroke::new(1.0, border_focused), - fg_stroke: Stroke::new(1.5, accent), + bg_stroke: Stroke::new(1.0_f32, border_focused), + fg_stroke: Stroke::new(1.5_f32, accent), ..v.widgets.hovered }, active: WidgetVisuals { bg_fill: bg_selection, weak_bg_fill: bg_selection, - bg_stroke: Stroke::new(1.0, accent), - fg_stroke: Stroke::new(2.0, accent), + bg_stroke: Stroke::new(1.0_f32, accent), + fg_stroke: Stroke::new(2.0_f32, accent), ..v.widgets.active }, open: WidgetVisuals { bg_fill: bg_highlight, weak_bg_fill: bg_highlight, - bg_stroke: Stroke::new(1.0, accent_secondary), - fg_stroke: Stroke::new(1.0, text_primary), + bg_stroke: Stroke::new(1.0_f32, accent_secondary), + fg_stroke: Stroke::new(1.0_f32, text_primary), ..v.widgets.open }, }; From c5320686e92c97d493300acc649ce1f4f213d294 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:04:23 -0700 Subject: [PATCH 05/12] test: gate feature-dependent integration tests on their features cargo test only compiled with the default or full feature set because several test files imported gradient, ratatui, and builtin APIs unconditionally. Any narrower feature selection failed to build the test targets even though the library itself compiles under every combination. CI runs --all-features so the gap went unnoticed. Gate adapter_tests, gradient_tests, and builtins_tests at the crate level, and gate the individual gradient tests inside loader_tests, resolver_tests, and builtins_tests. Verified with cargo test under --no-default-features, builtin-themes only, gradients only, ratatui only, and --all-features. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- tests/adapter_tests.rs | 2 ++ tests/builtins_tests.rs | 5 +++++ tests/gradient_tests.rs | 2 ++ tests/loader_tests.rs | 5 +++++ tests/resolver_tests.rs | 4 ++++ 5 files changed, 18 insertions(+) diff --git a/tests/adapter_tests.rs b/tests/adapter_tests.rs index b46bf95..77de7c2 100644 --- a/tests/adapter_tests.rs +++ b/tests/adapter_tests.rs @@ -1,3 +1,5 @@ +#![cfg(all(feature = "ratatui", feature = "gradients"))] + use opaline::{Gradient, OpalineColor, OpalineStyle}; use ratatui_core::style::{Color, Modifier, Style, Styled}; use ratatui_core::text::{Line, Span, Text}; diff --git a/tests/builtins_tests.rs b/tests/builtins_tests.rs index 8173e8a..1744927 100644 --- a/tests/builtins_tests.rs +++ b/tests/builtins_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "builtin-themes")] + use opaline::OpalineColor; use opaline::builtins; use opaline::names::{gradients, styles, tokens}; @@ -53,6 +55,7 @@ fn silkcircuit_neon_keyword_style_is_bold_purple() { assert!(kw.bold); } +#[cfg(feature = "gradients")] #[test] fn silkcircuit_neon_has_gradients() { let theme = builtins::silkcircuit_neon(); @@ -61,6 +64,7 @@ fn silkcircuit_neon_has_gradients() { assert!(theme.has_gradient("aurora")); } +#[cfg(feature = "gradients")] #[test] fn silkcircuit_neon_primary_gradient_endpoints() { let theme = builtins::silkcircuit_neon(); @@ -238,6 +242,7 @@ const REQUIRED_GRADIENTS: &[&str] = &[ gradients::AURORA, ]; +#[cfg(feature = "gradients")] #[test] fn all_builtins_have_required_gradients() { for &(id, _) in builtins::builtin_names() { diff --git a/tests/gradient_tests.rs b/tests/gradient_tests.rs index d4da116..1e6e765 100644 --- a/tests/gradient_tests.rs +++ b/tests/gradient_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "gradients")] + use opaline::{Gradient, OpalineColor}; use pretty_assertions::assert_eq; diff --git a/tests/loader_tests.rs b/tests/loader_tests.rs index 3eddb01..156699e 100644 --- a/tests/loader_tests.rs +++ b/tests/loader_tests.rs @@ -47,6 +47,7 @@ fn loaded_theme_resolves_styles() { assert_eq!(style, OpalineStyle::fg(OpalineColor::new(255, 0, 0)).bold()); } +#[cfg(feature = "gradients")] #[test] fn loaded_theme_resolves_gradients() { let theme = loader::load_from_str(MINIMAL_TOML, None).expect("valid TOML"); @@ -75,6 +76,7 @@ fn missing_style_returns_default() { assert_eq!(theme.style("nonexistent"), OpalineStyle::default()); } +#[cfg(feature = "gradients")] #[test] fn missing_gradient_returns_fallback() { let theme = loader::load_from_str(MINIMAL_TOML, None).expect("valid TOML"); @@ -95,6 +97,7 @@ fn has_style_checks() { assert!(!theme.has_style("nonexistent")); } +#[cfg(feature = "gradients")] #[test] fn has_gradient_checks() { let theme = loader::load_from_str(MINIMAL_TOML, None).expect("valid TOML"); @@ -130,6 +133,7 @@ fn theme_style_names() { assert!(names.contains(&"keyword")); } +#[cfg(feature = "gradients")] #[test] fn theme_gradient_names() { let theme = loader::load_from_str(MINIMAL_TOML, None).expect("valid TOML"); @@ -149,6 +153,7 @@ variant = "light" assert!(!theme.is_dark()); } +#[cfg(feature = "gradients")] #[test] fn empty_gradient_array_returns_error() { let toml = r#" diff --git a/tests/resolver_tests.rs b/tests/resolver_tests.rs index f359f22..84f5bc4 100644 --- a/tests/resolver_tests.rs +++ b/tests/resolver_tests.rs @@ -156,6 +156,7 @@ fn style_invalid_hex_returns_invalid_color() { assert!(matches!(err, OpalineError::InvalidColor { .. })); } +#[cfg(feature = "gradients")] #[test] fn gradient_resolves_stops() { let mut tf = minimal_theme_file(); @@ -173,6 +174,7 @@ fn gradient_resolves_stops() { assert_eq!(grad.at(1.0), OpalineColor::new(0, 0, 255)); } +#[cfg(feature = "gradients")] #[test] fn gradient_invalid_hex_returns_invalid_color() { let mut tf = minimal_theme_file(); @@ -185,6 +187,7 @@ fn gradient_invalid_hex_returns_invalid_color() { assert!(matches!(err, OpalineError::InvalidColor { .. })); } +#[cfg(feature = "gradients")] #[test] fn empty_gradient_returns_error() { let mut tf = minimal_theme_file(); @@ -194,6 +197,7 @@ fn empty_gradient_returns_error() { assert!(matches!(err, OpalineError::EmptyGradient)); } +#[cfg(feature = "gradients")] #[test] fn gradient_with_unresolvable_stops_returns_error() { let mut tf = minimal_theme_file(); From 85c7118566e1a93a869d0c8cb249bd0a61657cfb Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:07:46 -0700 Subject: [PATCH 06/12] docs: fix stale API references, feature tables, and add iced guide A docs audit against the current source turned up a cluster of drift: - The README CI badge pointed at ci.yml; the workflow is cicd.yml, so the badge rendered "repo or workflow not found". - The crate-level feature table on docs.rs listed 6 of 13 features and the adapter list omitted iced and the colored crate. - The iced adapter had no guide page and no sidebar entry even though README and the feature reference advertise it. Several adapter lists and the installation feature table also omitted iced. - The ratatui guide imported Stylize where the method lives on Styled, and two CircularReference patterns omitted the token field, so those snippets did not compile. - The CSS guide showed Catppuccin hex values and a diff-added class for Theme::default(), which is SilkCircuit Neon and has no such class. The example now shows real generator output. - The egui guide claimed text.primary drives override_text_color; the adapter leaves that None and uses text.primary for the open-widget stroke. It also claimed every color property is overridden. - Missing-token fallbacks were described as magenta in two places; the fallback is neutral gray. - The widgets feature also enables ratatui, name-based global loading needs builtin-themes, the syntect settings table missed three fields, the error table missed ThemeNotFound, and the custom-themes guide implied tilde expansion that the loader does not perform. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- AGENTS.md | 4 +- README.md | 2 +- docs/.vitepress/config.ts | 3 +- docs/getting-started/index.md | 4 +- docs/getting-started/installation.md | 3 +- docs/guide/css.md | 25 +++++---- docs/guide/custom-themes.md | 4 +- docs/guide/egui.md | 5 +- docs/guide/iced.md | 78 ++++++++++++++++++++++++++++ docs/guide/ratatui.md | 5 +- docs/guide/syntect.md | 20 +++---- docs/guide/theme-selector.md | 2 +- docs/guide/themes.md | 2 +- docs/index.md | 4 +- docs/reference/api.md | 2 +- docs/reference/errors.md | 7 +-- docs/reference/features.md | 2 +- src/adapters/css.rs | 9 ++-- src/adapters/egui.rs | 7 +-- src/lib.rs | 10 +++- 20 files changed, 150 insertions(+), 48 deletions(-) create mode 100644 docs/guide/iced.md diff --git a/AGENTS.md b/AGENTS.md index 295a9af..07f8e82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,8 @@ tests/ syntect_tests.rs # Color, StyleModifier, Theme generation egui_tests.rs # Color32, Visuals, widget visuals, selection iced_tests.rs # Color, Palette mapping, Extended palette, Custom theme + theme_tests.rs # register_default_token / register_default_style semantics + theme_selector_tests.rs # Widget key handling, filter, Esc restore docs/ # VitePress documentation site (SilkCircuit OKLCH theme) ``` @@ -84,7 +86,7 @@ cd docs && pnpm build # Build docs for deployment ## Key Types -- `OpalineColor` — RGB color with hex, tuple, array, u32 conversions + lerp +- `OpalineColor` — RGB color with hex, tuple, array, u32 conversions + lerp + darken/lighten/desaturate - `OpalineStyle` — Composed style (fg, bg, 9 modifiers) with builder pattern, `#[non_exhaustive]` - `Gradient` — Multi-stop color interpolation (new() panics, try_new() returns Result) - `Theme` — Fully resolved theme with `color()`, `style()`, `gradient()` + strict `try_*` variants; ratatui `span()`, `line()`, `text()`, `gradient_text()` (no trait import needed) diff --git a/README.md b/README.md index e0824d4..be8f800 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ docs.rs - CI + CI 39 Themes diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 22885d2..7ac128f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -17,7 +17,7 @@ export default defineConfig({ 'meta', { property: 'og:description', - content: 'Token-based theme engine with 39 builtin themes, gradients, and adapters for ratatui, egui, crossterm, syntect, and more', + content: 'Token-based theme engine with 39 builtin themes, gradients, and adapters for ratatui, egui, iced, crossterm, syntect, and more', }, ], ], @@ -63,6 +63,7 @@ export default defineConfig({ { text: 'CSS Adapter', link: '/guide/css' }, { text: 'Syntect Adapter', link: '/guide/syntect' }, { text: 'egui Adapter', link: '/guide/egui' }, + { text: 'iced Adapter', link: '/guide/iced' }, { text: 'Color Manipulation', link: '/guide/color-manipulation' }, { text: 'App-Level Derivation', link: '/guide/derivation' }, { text: 'ThemeBuilder', link: '/guide/builder' }, diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index bdb1b66..7543269 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -1,6 +1,6 @@ # Introduction -Opaline is a **token-based theme engine** for Rust applications. It gives your app a complete theming system: raw hex colors resolve to semantic tokens, which compose into styles, all driven by TOML configuration. While Opaline ships with first-class ratatui support, adapters for egui, crossterm, owo-colors, syntect, and CSS make it work across terminal, GUI, and web targets. +Opaline is a **token-based theme engine** for Rust applications. It gives your app a complete theming system: raw hex colors resolve to semantic tokens, which compose into styles, all driven by TOML configuration. While Opaline ships with first-class ratatui support, adapters for egui, iced, crossterm, owo-colors, syntect, and CSS make it work across terminal, GUI, and web targets. ## Why Opaline? @@ -55,7 +55,7 @@ This separation means palette swaps propagate through the entire theme automatic | **39 builtin themes** | SilkCircuit, Catppuccin, GitHub, Monokai Pro, Ayu, Night Owl, Flexoki, Palenight, Rose Pine, Everforest, Tokyo Night, Kanagawa, Dracula, Nord, Gruvbox, Solarized, One | | **Token system** | 28 core semantic tokens across generic namespaces | | **Gradients** | Multi-stop color interpolation with `at(t)` and `generate(n)` | -| **7 adapters** | ratatui, egui, crossterm, owo-colors, syntect, CSS, colored | +| **8 adapters** | ratatui, egui, iced, crossterm, owo-colors, syntect, CSS, colored | | **ThemeBuilder** | Programmatic theme construction without TOML | | **Strict resolver** | Cycle detection, unresolvable reference errors | | **Zero unsafe** | `unsafe_code = "forbid"`, no exceptions | diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index d6e692f..d3eb97c 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -30,7 +30,7 @@ opaline = { version = "0.4", features = ["global-state"] } opaline = { version = "0.4", features = [ "builtin-themes", "gradients", "ratatui", "cli", "crossterm", "owo-colors", "css", - "syntect", "egui", + "syntect", "egui", "iced", "global-state", "discovery", "widgets" ] } ``` @@ -46,6 +46,7 @@ opaline = { version = "0.4", features = [ | `css` | no | CSS custom properties + classes generation | | `syntect` | no | Syntax highlighting theme generation | | `egui` | no | `Color32`/`Visuals` adapter for egui | +| `iced` | no | `Color`/`Palette`/`Custom` adapter for iced | | `global-state` | no | Process-wide `current()`/`set_theme()` singleton | | `discovery` | no | Load user themes from `~/.config//themes/` | | `widgets` | no | Theme selector widget with live preview | diff --git a/docs/guide/css.md b/docs/guide/css.md index b9039dd..2479c3f 100644 --- a/docs/guide/css.md +++ b/docs/guide/css.md @@ -22,16 +22,18 @@ Output: ```css :root { - --opaline-accent-primary: #cba6f7; - --opaline-bg-base: #1e1e2e; - --opaline-text-primary: #cdd6f4; - /* ... 39 token variables */ + --opaline-accent-primary: #e135ff; + --opaline-bg-base: #121218; + --opaline-text-primary: #f8f8f2; + /* ... 28 token variables */ --opaline-gradient-primary: linear-gradient(to right, #e135ff, #80ffea); --opaline-gradient-aurora: linear-gradient( to right, #e135ff, - #80ffea, - #ff6ac1 + #f31bff, + #ff00ff, + #bf80f4, + #80ffea ); } ``` @@ -53,16 +55,17 @@ Output: ```css .opaline-keyword { - color: #cba6f7; + color: #e135ff; font-weight: bold; } .opaline-error-style { - color: #f38ba8; + color: #ff6363; } -.opaline-diff-added { - color: #a6e3a1; +.opaline-inline-code { + color: #50fa7b; + background-color: #1e1e28; } ``` @@ -79,6 +82,8 @@ Style modifiers map to CSS properties: | `crossed_out` | `text-decoration: line-through` | | `hidden` | `visibility: hidden` | +`reversed`, `slow_blink`, and `rapid_blink` have no CSS equivalent and are skipped. + ## Complete Stylesheet Generate both variables and classes in one call: diff --git a/docs/guide/custom-themes.md b/docs/guide/custom-themes.md index 00430c2..629cb8f 100644 --- a/docs/guide/custom-themes.md +++ b/docs/guide/custom-themes.md @@ -90,8 +90,8 @@ aurora = ["accent", "secondary", "green", "blue", "accent"] ## Loading Custom Themes ```rust -// From a file -let theme = opaline::load_from_file("~/.config/myapp/themes/custom.toml")?; +// From a file (paths are used as given; expand `~` yourself first) +let theme = opaline::load_from_file("themes/custom.toml")?; // From a string (e.g., embedded or fetched) let toml_str = std::fs::read_to_string("theme.toml")?; diff --git a/docs/guide/egui.md b/docs/guide/egui.md index 2a52ee7..4c7ed2e 100644 --- a/docs/guide/egui.md +++ b/docs/guide/egui.md @@ -34,7 +34,7 @@ let visuals = to_egui_visuals(&theme); ctx.set_visuals(visuals); ``` -The function starts from `Visuals::dark()` or `Visuals::light()` based on the theme variant, then overrides all color properties. Non-color properties (corner radii, shadows, spacing) retain their sensible defaults. +The function starts from `Visuals::dark()` or `Visuals::light()` based on the theme variant, then overrides the main color properties. Text cursor, shadow, and text-edit colors keep egui's defaults, as do non-color properties (corner radii, shadows, spacing). ### Token → Visuals Mapping @@ -45,7 +45,8 @@ The function starts from `Visuals::dark()` or `Visuals::light()` based on the th | `bg.highlight` | `faint_bg_color`, `widgets.hovered.bg_fill` | | `bg.code` | `code_bg_color` | | `bg.selection` | `selection.bg_fill`, `widgets.active.bg_fill` | -| `text.primary` | `override_text_color` | +| `text.primary` | `widgets.open.fg_stroke` | +| `bg.base` ±0.5 | `extreme_bg_color` (darkened for dark themes, lightened for light) | | `text.secondary` | `widgets.inactive.fg_stroke` | | `text.muted` | `widgets.noninteractive.fg_stroke` | | `accent.primary` | `hyperlink_color`, `selection.stroke`, `widgets.hovered.fg_stroke` | diff --git a/docs/guide/iced.md b/docs/guide/iced.md new file mode 100644 index 0000000..7e9902f --- /dev/null +++ b/docs/guide/iced.md @@ -0,0 +1,78 @@ +# iced Adapter + +The `iced` feature maps Opaline themes onto [iced](https://iced.rs/)'s `Palette` and `Custom` theme types, so a single TOML theme drives both your terminal UI and your desktop GUI. + +```toml +[dependencies] +opaline = { version = "0.4", features = ["iced"] } +``` + +## Color Conversion + +`OpalineColor` converts to `iced::Color`: + +```rust +use opaline::OpalineColor; +use iced::Color; + +let color = OpalineColor::new(225, 53, 255); +let iced_color: Color = color.into(); +// → Color::from_rgb8(225, 53, 255) +``` + +## Palette Generation + +Convert a full Opaline theme to an iced `Palette`: + +```rust +use opaline::adapters::iced::to_iced_palette; + +let theme = opaline::Theme::default(); +let palette = to_iced_palette(&theme); +``` + +### Token → Palette Mapping + +| Opaline Token | Palette Field | +| ---------------- | ------------- | +| `bg.base` | `background` | +| `text.primary` | `text` | +| `accent.primary` | `primary` | +| `success` | `success` | +| `warning` | `warning` | +| `error` | `danger` | + +iced derives the rest of its widget colors (weak, strong, hover variants) from these six slots. + +## Custom Theme + +`to_iced_custom` wraps the palette in an iced `Custom` theme named after the Opaline theme. Drop it into `iced::Theme::Custom`: + +```rust +use std::sync::Arc; +use iced::Theme; +use opaline::adapters::iced::to_iced_custom; + +let theme = opaline::Theme::default(); +let custom = to_iced_custom(&theme); +let iced_theme = Theme::Custom(Arc::new(custom)); +``` + +Return `iced_theme` from your application's `theme` method and every widget picks it up. + +If you need the derived tints directly, `to_iced_extended` returns the `Extended` palette that iced generates from the base palette. + +## Runtime Theme Switching + +```rust +use std::sync::Arc; +use iced::Theme; +use opaline::adapters::iced::to_iced_custom; + +fn switch_theme(theme_name: &str) -> Theme { + let theme = opaline::load_by_name(theme_name).expect("valid theme"); + Theme::Custom(Arc::new(to_iced_custom(&theme))) +} +``` + +All 39 builtin themes work with iced. iced decides between its dark and light widget styling from the lightness of `background`, which agrees with each builtin theme's declared variant. diff --git a/docs/guide/ratatui.md b/docs/guide/ratatui.md index 31a1b46..4fbfb7a 100644 --- a/docs/guide/ratatui.md +++ b/docs/guide/ratatui.md @@ -63,9 +63,8 @@ let bg = Style::default().bg(theme.color("bg.base").into()); `OpalineStyle` implements Ratatui's `Styled` trait: ```rust -use opaline::OpalineStyle; -use ratatui::style::Stylize; -use ratatui::text::Span; +use opaline::{OpalineColor, OpalineStyle}; +use ratatui::style::Styled; let style = OpalineStyle::fg(OpalineColor::new(225, 53, 255)).bold(); let rat_style: ratatui::style::Style = style.style(); diff --git a/docs/guide/syntect.md b/docs/guide/syntect.md index c580182..10a4053 100644 --- a/docs/guide/syntect.md +++ b/docs/guide/syntect.md @@ -88,15 +88,17 @@ Editor-level settings are derived from semantic tokens: | Opaline Token | ThemeSettings Field | | ------------------ | ------------------- | -| `text.primary` | `foreground` | -| `bg.base` | `background` | -| `accent.primary` | `caret`, `accent` | -| `bg.highlight` | `line_highlight` | -| `bg.selection` | `selection` | -| `bg.panel` | `gutter` | -| `text.dim` | `gutter_foreground` | -| `border.focused` | `active_guide` | -| `border.unfocused` | `guide` | +| `text.primary` | `foreground`, `selection_foreground` | +| `bg.base` | `background` | +| `accent.primary` | `caret`, `accent` | +| `accent.secondary` | `brackets_foreground` | +| `bg.highlight` | `line_highlight` | +| `bg.selection` | `selection` | +| `bg.panel` | `gutter` | +| `text.dim` | `gutter_foreground` | +| `warning` | `find_highlight` | +| `border.focused` | `active_guide` | +| `border.unfocused` | `guide` | ### Style Modifiers diff --git a/docs/guide/theme-selector.md b/docs/guide/theme-selector.md index 55542e7..4324cbc 100644 --- a/docs/guide/theme-selector.md +++ b/docs/guide/theme-selector.md @@ -11,7 +11,7 @@ The widget requires the `widgets` feature: opaline = { version = "0.4", features = ["widgets"] } ``` -This enables `global-state` and `builtin-themes` automatically, and pulls in full `ratatui` (with crossterm) rather than just `ratatui-core`. +This enables `global-state`, `builtin-themes`, and the `ratatui` adapter automatically, and pulls in full `ratatui` (with crossterm) rather than just `ratatui-core`. It also makes file-backed themes shadow builtin ids during discovery and live preview, so a local `dracula.toml` will win over the shipped `dracula` theme. ## Quick Start diff --git a/docs/guide/themes.md b/docs/guide/themes.md index 647bbcf..10f02cf 100644 --- a/docs/guide/themes.md +++ b/docs/guide/themes.md @@ -116,7 +116,7 @@ Circular references are also detected: ```rust // "a" = "b", "b" = "a" -// → OpalineError::CircularReference { chain: ["a", "b", "a"] } +// → OpalineError::CircularReference { token: "a", chain: ["a", "b", "a"] } ``` ## Variant Helpers diff --git a/docs/index.md b/docs/index.md index 4515f03..f74e356 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: Opaline text: Theme Engine for Rust - tagline: Token-based themes with 39 builtins, gradients, and adapters for ratatui, egui, crossterm, syntect, and more + tagline: Token-based themes with 39 builtins, gradients, and adapters for ratatui, egui, iced, crossterm, syntect, and more actions: - theme: brand text: Get Started @@ -25,7 +25,7 @@ features: details: Multi-stop color gradients with linear interpolation. Perfect for progress bars, status indicators, and decorative elements. - icon: "\u26A1" title: Multi-Framework Adapters - details: "First-class adapters for ratatui, egui, crossterm, owo-colors, syntect, and CSS. One theme, every target." + details: "First-class adapters for ratatui, egui, iced, crossterm, owo-colors, syntect, and CSS. One theme, every target." - icon: "\U0001F527" title: TOML-Driven details: Define themes in clean TOML files. Palette, tokens, styles, and gradients all declaratively configured. diff --git a/docs/reference/api.md b/docs/reference/api.md index 29ab718..852547d 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -242,7 +242,7 @@ let rainbow = gradient_string("text", grad); // String ## Global State -Requires `global-state` feature. +Requires the `global-state` feature. The `load_theme_by_name*` functions also require `builtin-themes`. ```rust use opaline::{current, load_theme, load_theme_by_name, set_theme, Theme}; diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 90ef219..74ca522 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -17,6 +17,7 @@ use opaline::OpalineError; | `InvalidColor` | Hex string isn't a valid color | `"#xyz"`, `"not-a-color"` | | `UnresolvedToken` | Token references unknown palette/token | `"accent.primary" = "nonexistent"` | | `CircularReference` | Tokens form a cycle | `a → b → c → a` | +| `ThemeNotFound` | Name-based loading finds no theme | `load_theme_by_name("nope")` | | `EmptyGradient` | Gradient has no stops | `gradient = []` | ### Handling @@ -36,7 +37,7 @@ match opaline::load_from_file("theme.toml") { Err(OpalineError::UnresolvedToken { token, reference }) => { eprintln!("Token '{token}' references unknown '{reference}'"); } - Err(OpalineError::CircularReference { chain }) => { + Err(OpalineError::CircularReference { chain, .. }) => { eprintln!("Circular: {}", chain.join(" → ")); } Err(e) => eprintln!("Other: {e}"), @@ -56,9 +57,9 @@ This means a theme that loads successfully is guaranteed to have all its referen ::: tip For fallback-safe access at runtime, use the non-strict methods: -- `theme.color("token")` returns a magenta fallback on miss +- `theme.color("token")` returns a neutral gray fallback on miss - `theme.style("name")` returns the default style on miss -- `theme.gradient("name", t)` returns a magenta fallback on miss +- `theme.gradient("name", t)` returns a neutral gray fallback on miss For strict access that returns `Option`: diff --git a/docs/reference/features.md b/docs/reference/features.md index 5d52598..0503156 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -27,7 +27,7 @@ These must be explicitly enabled: | `iced` | iced GUI adapter: `Color`, `Palette`, `Custom` theme from theme tokens | `iced_core 0.14` | | `global-state` | Process-wide theme singleton: `current()`, `set_theme()` | `parking_lot 0.12` | | `discovery` | User theme directory scanning: `app_theme_dirs()`, `theme_dirs()` | `dirs 6` | -| `widgets` | Theme selector widget with live preview | `ratatui 0.30`, `crossterm 0.29`, `unicode-width 0.2` (enables `global-state` + `builtin-themes`) | +| `widgets` | Theme selector widget with live preview | `ratatui 0.30`, `crossterm 0.29`, `unicode-width 0.2` (enables `ratatui`, `global-state`, and `builtin-themes`) | ## Configuration Examples diff --git a/src/adapters/css.rs b/src/adapters/css.rs index 3d5c903..99b6d3c 100644 --- a/src/adapters/css.rs +++ b/src/adapters/css.rs @@ -7,7 +7,7 @@ //! let theme = Theme::default(); //! let css = opaline::adapters::css::generate_stylesheet(&theme); //! // :root { -//! // --opaline-accent-primary: #cba6f7; +//! // --opaline-accent-primary: #e135ff; //! // ... //! // } //! ``` @@ -17,7 +17,7 @@ use crate::theme::Theme; /// Generate CSS custom properties from all theme tokens. /// /// Token names are prefixed with `--opaline-` and dots/underscores become dashes: -/// `accent.primary` → `--opaline-accent-primary: #cba6f7;` +/// `accent.primary` → `--opaline-accent-primary: #e135ff;` /// /// When the `gradients` feature is enabled, gradient stops are emitted as /// `linear-gradient(to right, ...)` values. @@ -58,7 +58,10 @@ pub fn generate_css_vars(theme: &Theme) -> String { /// Generate CSS classes from all theme styles. /// /// Style names are prefixed with `.opaline-` and underscores become dashes: -/// `keyword` → `.opaline-keyword { color: #cba6f7; font-weight: bold; }` +/// `keyword` → `.opaline-keyword { color: #e135ff; font-weight: bold; }` +/// +/// Modifiers with no CSS equivalent (`reversed`, `slow_blink`, `rapid_blink`) +/// are skipped. pub fn generate_css_classes(theme: &Theme) -> String { let mut blocks = Vec::new(); diff --git a/src/adapters/egui.rs b/src/adapters/egui.rs index dbcd4a0..f4808ea 100644 --- a/src/adapters/egui.rs +++ b/src/adapters/egui.rs @@ -3,7 +3,7 @@ //! Provides `From` conversions for [`Color32`] and a //! [`to_egui_visuals`] function that maps theme tokens onto egui's //! [`Visuals`], starting from the appropriate dark/light base and -//! overriding all color properties. +//! overriding the main color properties. //! //! ```rust,ignore //! let theme = opaline::Theme::default(); @@ -40,8 +40,9 @@ impl From<&OpalineColor> for Color32 { /// Convert an Opaline [`Theme`] to egui [`Visuals`]. /// /// Starts from [`Visuals::dark()`] or [`Visuals::light()`] based on the -/// theme variant, then overrides all color-related fields from theme tokens. -/// Non-color fields (corner radii, shadows, expansion) retain their defaults. +/// theme variant, then overrides the main color fields from theme tokens. +/// Text cursor, shadow, and text-edit colors keep egui's defaults, as do +/// non-color fields (corner radii, shadows, expansion). pub fn to_egui_visuals(theme: &Theme) -> Visuals { let mut v = if theme.is_dark() { Visuals::dark() diff --git a/src/lib.rs b/src/lib.rs index 68bfc2a..29a7ca2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,8 @@ //! pipeline: **palette** (raw hex colors) → **tokens** (semantic names) → **styles** //! (composed fg/bg + modifiers). Themes can also define multi-stop **gradients**. //! -//! Adapters are available for ratatui, egui, crossterm, owo-colors, syntect, and CSS. +//! Adapters are available for ratatui, egui, iced, crossterm, owo-colors, syntect, +//! the `colored` crate, and CSS. //! //! ## Quick start //! @@ -29,8 +30,15 @@ //! | `gradients` | yes | Multi-stop gradient support | //! | `ratatui` | yes | `From` impls for `ratatui::style::{Color, Style}` | //! | `cli` | no | `colored` crate adapter for ANSI terminal output | +//! | `crossterm` | no | Direct crossterm `Color`/`ContentStyle` adapter | +//! | `owo-colors` | no | owo-colors zero-allocation terminal adapter | +//! | `css` | no | CSS custom properties + classes generation | +//! | `syntect` | no | Syntax highlighting theme generation | +//! | `egui` | no | egui `Visuals`/`Color32` adapter | +//! | `iced` | no | iced `Custom`/`Palette`/`Color` adapter | //! | `global-state` | no | Process-wide `current()`/`set_theme()` singleton | //! | `discovery` | no | Load user themes from `~/.config//themes/` | +//! | `widgets` | no | Theme selector widget with live preview | pub mod color; pub mod error; From a7afe3df1c95cddd8344f8f8a5d2c1aae67ed5cd Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:07:46 -0700 Subject: [PATCH 07/12] fix(owo-colors): map rapid_blink to SGR 6 instead of folding into blink The owo-colors adapter collapsed both blink modifiers into blink(), which emits SGR 5 (slow blink). owo-colors exposes blink_fast() for SGR 6, and the crossterm and ratatui adapters already keep the two distinct, so a style with rapid_blink rendered differently depending on which terminal adapter was in use. A test now pins both codes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- src/adapters/owo_colors.rs | 5 ++++- tests/owo_colors_tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/adapters/owo_colors.rs b/src/adapters/owo_colors.rs index e3d518c..090f861 100644 --- a/src/adapters/owo_colors.rs +++ b/src/adapters/owo_colors.rs @@ -54,9 +54,12 @@ fn build_owo_style(s: &OpalineStyle) -> Style { if s.underline { style = style.underline(); } - if s.slow_blink || s.rapid_blink { + if s.slow_blink { style = style.blink(); } + if s.rapid_blink { + style = style.blink_fast(); + } if s.reversed { style = style.reversed(); } diff --git a/tests/owo_colors_tests.rs b/tests/owo_colors_tests.rs index f5f0f8a..b7e6efb 100644 --- a/tests/owo_colors_tests.rs +++ b/tests/owo_colors_tests.rs @@ -47,6 +47,23 @@ fn all_modifiers_applied() { assert!(output.len() > 1); } +#[test] +fn slow_and_rapid_blink_map_to_distinct_sgr_codes() { + let slow: owo_colors::Style = OpalineStyle::new().slow_blink().into(); + let rapid: owo_colors::Style = OpalineStyle::new().rapid_blink().into(); + let slow_out = format!("{}", "x".style(slow)); + let rapid_out = format!("{}", "x".style(rapid)); + + assert!( + slow_out.contains("\x1b[5m"), + "slow blink is SGR 5: {slow_out:?}" + ); + assert!( + rapid_out.contains("\x1b[6m"), + "rapid blink is SGR 6: {rapid_out:?}" + ); +} + #[test] fn theme_owo_style() { let theme = Theme::builder("Test") From 48e64ab9653384c368de3cd51f229bada95ae787 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:09:17 -0700 Subject: [PATCH 08/12] fix(widgets): handle key kinds, chords, first-key Enter, scrolled header Four confirmed defects in the theme selector's input and rendering: - handle_key matched on key code alone. Terminals that report key-up (Windows, the kitty protocol) fired every action twice, so one Down moved two rows and each typed character landed twice. Release events are now ignored. - Ctrl, Alt, and Super chords arrived as plain characters and were appended to the filter, swallowing Ctrl+C and friends. Those chords now return Noop so the host app can handle them. - Enter as the first key returned Select(id) for the highlighted theme without applying it, because the preview only ran on navigation. An app that persisted the id then showed one theme and saved another. Enter now applies the preview before reporting. - The sticky section header always rendered the list's first section, so a window scrolled into the light themes read "Dark Themes" above a column of light entries. Only visible items now feed the header state, so the first visible row carries its own section heading. Each fix has a regression test that fails against the previous code. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- src/widgets/theme_selector.rs | 61 +++++++++++++-------- tests/theme_selector_tests.rs | 99 ++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 25 deletions(-) diff --git a/src/widgets/theme_selector.rs b/src/widgets/theme_selector.rs index 0c52bc7..14409ba 100644 --- a/src/widgets/theme_selector.rs +++ b/src/widgets/theme_selector.rs @@ -24,7 +24,7 @@ use std::sync::Arc; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -156,6 +156,12 @@ impl ThemeSelectorState { /// Handle a key event. Returns the action taken. pub fn handle_key(&mut self, key: KeyEvent) -> ThemeSelectorAction { + // Terminals that report key-up (Windows, the kitty protocol) would + // otherwise fire every action twice. + if key.kind == KeyEventKind::Release { + return ThemeSelectorAction::Noop; + } + match key.code { KeyCode::Up => { if self.filtered_indices.is_empty() { @@ -188,7 +194,9 @@ impl ThemeSelectorState { KeyCode::Enter => { if let Some(&idx) = self.filtered_indices.get(self.cursor) { let id = self.themes[idx].name.clone(); - // Theme is already applied as preview — just confirm + // Enter may be the first key pressed, so make the global + // theme match the id we hand back. + self.apply_preview(); ThemeSelectorAction::Select(id) } else { ThemeSelectorAction::Noop @@ -200,6 +208,13 @@ impl ThemeSelectorState { ThemeSelectorAction::Cancel } KeyCode::Char(c) => { + // Chords like Ctrl+C belong to the host app, not the filter. + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + return ThemeSelectorAction::Noop; + } self.filter.push(c); self.recompute_filter(); self.apply_preview(); @@ -443,33 +458,33 @@ fn render_theme_entries( break; } - let info = &state.themes[theme_idx]; - - // Section header on variant boundary - if last_variant != Some(info.variant) { - if (items_rendered >= state.scroll || last_variant.is_none()) && y < max_y { - let header_text = match info.variant { - ThemeVariant::Dark => " Dark Themes", - ThemeVariant::Light => " Light Themes", - }; - let header = Line::from(Span::styled( - header_text, - Style::default() - .fg(text_muted) - .add_modifier(Modifier::ITALIC), - )); - header.render(Rect::new(list_area.x, y, list_area.width, 1), buf); - y += 1; - } - last_variant = Some(info.variant); - } - // Skip items before scroll window if items_rendered < state.scroll { items_rendered += 1; continue; } + let info = &state.themes[theme_idx]; + + // Section header on variant boundary. Only visible items feed + // `last_variant`, so the first row of the window always carries the + // heading for its own section rather than the list's first section. + if last_variant != Some(info.variant) { + let header_text = match info.variant { + ThemeVariant::Dark => " Dark Themes", + ThemeVariant::Light => " Light Themes", + }; + let header = Line::from(Span::styled( + header_text, + Style::default() + .fg(text_muted) + .add_modifier(Modifier::ITALIC), + )); + header.render(Rect::new(list_area.x, y, list_area.width, 1), buf); + y += 1; + last_variant = Some(info.variant); + } + if y >= max_y { break; } diff --git a/tests/theme_selector_tests.rs b/tests/theme_selector_tests.rs index 570f52f..ca50178 100644 --- a/tests/theme_selector_tests.rs +++ b/tests/theme_selector_tests.rs @@ -2,10 +2,13 @@ use std::sync::{Mutex, MutexGuard, OnceLock}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::StatefulWidget; use opaline::widgets::wrap_text; -use opaline::{Theme, ThemeSelectorAction, ThemeSelectorState, current, set_theme}; +use opaline::{Theme, ThemeSelector, ThemeSelectorAction, ThemeSelectorState, current, set_theme}; fn global_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); @@ -82,3 +85,95 @@ fn wrap_text_uses_display_width_not_byte_length() { let result = wrap_text("é é", 3); assert_eq!(result, vec!["é é"]); } + +#[test] +fn release_events_are_ignored() { + let _guard = global_lock(); + let previous = current(); + + let mut state = ThemeSelectorState::new(); + let release = KeyEvent::new_with_kind( + KeyCode::Char('a'), + KeyModifiers::NONE, + KeyEventKind::Release, + ); + + assert_eq!(state.handle_key(release), ThemeSelectorAction::Noop); + assert_eq!(state.filter(), ""); + + set_theme((*previous).clone()); +} + +#[test] +fn control_chords_do_not_enter_the_filter() { + let _guard = global_lock(); + let previous = current(); + + let mut state = ThemeSelectorState::new(); + + assert_eq!( + state.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ThemeSelectorAction::Noop + ); + assert_eq!( + state.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT)), + ThemeSelectorAction::Noop + ); + assert_eq!(state.filter(), ""); + + // Shift is just an uppercase character, which is still search input. + assert_eq!( + state.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT)), + ThemeSelectorAction::FilterChanged + ); + assert_eq!(state.filter(), "N"); + + set_theme((*previous).clone()); +} + +#[test] +fn enter_without_navigation_applies_the_reported_theme() { + let _guard = global_lock(); + let previous = current(); + + set_theme(opaline::load_by_name("nord").expect("nord loads")); + let mut state = ThemeSelectorState::new(); + let expected = state.selected_theme().expect("list is not empty").clone(); + + let action = state.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(action, ThemeSelectorAction::Select(expected.name.clone())); + assert_eq!(current().meta.name, expected.display_name); + + set_theme((*previous).clone()); +} + +#[test] +fn scrolled_list_shows_the_heading_for_the_visible_section() { + let _guard = global_lock(); + let previous = current(); + + let mut state = ThemeSelectorState::new(); + for _ in 0..100 { + state.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + + let area = Rect::new(0, 0, 80, 12); + let mut buf = Buffer::empty(area); + ThemeSelector::new().render(area, &mut buf, &mut state); + + let rows: Vec = (0..area.height) + .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect()) + .collect(); + + assert!( + rows.iter().any(|row| row.contains("Light Themes")), + "expected the light heading in {rows:#?}" + ); + assert!( + !rows.iter().any(|row| row.contains("Dark Themes")), + "dark heading should not be visible in {rows:#?}" + ); + + set_theme((*previous).clone()); +} From 35436e73dc85d392101327c241300a7496499d64 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:09:17 -0700 Subject: [PATCH 09/12] fix(example): render contract styles in the showcase samples panel Half of the style samples used names no builtin theme defines (file_path, commit_hash, diff_added, git_staged, author, timestamp and friends), so those rows rendered unstyled and the demo taught names that live in consuming apps, not in the core contract. The info sample also hardcoded a stale theme count. Sample the 14 contract styles instead, including the newly promoted cursor_line, and read the theme count from BUILTIN_COUNT. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- examples/theme_showcase.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/examples/theme_showcase.rs b/examples/theme_showcase.rs index 14da542..0b2cf73 100644 --- a/examples/theme_showcase.rs +++ b/examples/theme_showcase.rs @@ -232,23 +232,27 @@ fn render_styles(frame: &mut Frame, app: &App, area: Rect) { } fn render_style_samples(frame: &mut Frame, app: &App, area: Rect) { + let loaded = format!( + "\u{2139} {} themes loaded", + opaline::builtins::BUILTIN_COUNT + ); + // Every name here is part of the theme contract, so each row renders + // with real colors in all builtin themes. let samples: &[(&str, &str)] = &[ ("keyword", "fn main()"), - ("file_path", "src/lib.rs"), - ("commit_hash", "a1b2c3d"), + ("line_number", " 42 \u{2502}"), + ("cursor_line", "line under the cursor"), + ("selected", "selected item"), + ("active_selected", "active + selected item"), + ("focused_border", "\u{2503} focused panel"), + ("unfocused_border", "\u{2503} unfocused panel"), ("success_style", "\u{2713} Tests passed"), ("error_style", "\u{2717} Build failed"), ("warning_style", "\u{26a0} Deprecated"), - ("info_style", "\u{2139} 20 themes loaded"), + ("info_style", loaded.as_str()), ("dimmed", "subtle hint text"), ("muted", "secondary content"), ("inline_code", " let x = 42 "), - ("diff_added", "+ added line"), - ("diff_removed", "- removed line"), - ("git_staged", "\u{25cf} staged"), - ("git_modified", "\u{25cf} modified"), - ("author", "hyperb1iss"), - ("timestamp", "2024-01-15 09:30"), ]; let label_style = Style::default().fg(app.theme.color("text.secondary").into()); From f35d97b8f422f5590044fb5f774d65a82cb4a7c7 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:20:59 -0700 Subject: [PATCH 10/12] docs: drop the hardcoded test count from the dev command tables Both tables quoted a fixed number that was already stale and drifts with every added test. The command is the useful part; the count rots. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- AGENTS.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 07f8e82..f7dad5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ docs/ # VitePress documentation site (SilkCircuit OKLCH theme) ```bash cargo check # Fast type check cargo clippy --all-targets --all-features # Pedantic lint gate -cargo test --all-features # Full test suite (210 tests) +cargo test --all-features # Full test suite cargo doc --all-features --open # Generate docs cd docs && pnpm dev # VitePress dev server cd docs && pnpm build # Build docs for deployment diff --git a/README.md b/README.md index be8f800..090f9e7 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ TOML → ThemeFile (serde) → Resolver → Theme ```bash cargo check # Fast type check cargo clippy --all-targets --all-features # Pedantic lint gate -cargo test --all-features # Full test suite (210 tests) +cargo test --all-features # Full test suite cargo doc --all-features --open # Generate docs cargo run --example theme-showcase # Interactive TUI demo ``` From c930409f9c2453aaaf405199fb820f9222e7dd0d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:20:59 -0700 Subject: [PATCH 11/12] fix(widgets): never draw a section heading on the last list row When a variant boundary landed exactly on the final visible row, the heading rendered with no entry beneath it, so the list ended on an orphaned "Light Themes" line. A heading now requires room for at least its first item. A test sweeps heights around the boundary and asserts that a heading always has an entry under it; it fails on the previous code at height 34. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- src/widgets/theme_selector.rs | 4 ++++ tests/theme_selector_tests.rs | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/widgets/theme_selector.rs b/src/widgets/theme_selector.rs index 14409ba..1d3da08 100644 --- a/src/widgets/theme_selector.rs +++ b/src/widgets/theme_selector.rs @@ -470,6 +470,10 @@ fn render_theme_entries( // `last_variant`, so the first row of the window always carries the // heading for its own section rather than the list's first section. if last_variant != Some(info.variant) { + // A heading with no room for its first item underneath is noise. + if y + 1 >= max_y { + break; + } let header_text = match info.variant { ThemeVariant::Dark => " Dark Themes", ThemeVariant::Light => " Light Themes", diff --git a/tests/theme_selector_tests.rs b/tests/theme_selector_tests.rs index ca50178..8763a64 100644 --- a/tests/theme_selector_tests.rs +++ b/tests/theme_selector_tests.rs @@ -177,3 +177,40 @@ fn scrolled_list_shows_the_heading_for_the_visible_section() { set_theme((*previous).clone()); } + +#[test] +fn section_heading_is_never_the_last_list_row() { + let _guard = global_lock(); + let previous = current(); + + let dark_count = opaline::list_available_themes() + .iter() + .filter(|info| info.variant == opaline::ThemeVariant::Dark) + .count(); + let dark_count = u16::try_from(dark_count).expect("fits in u16"); + + // Sweep heights around the point where the light section's heading + // lands on the final visible row. Whatever the chrome costs, one of + // these heights hits it, and a heading with nothing beneath it is the + // defect under test. + for height in (dark_count + 2)..=(dark_count + 14) { + let mut state = ThemeSelectorState::new(); + let area = Rect::new(0, 0, 80, height); + let mut buf = Buffer::empty(area); + ThemeSelector::new().render(area, &mut buf, &mut state); + + let rows: Vec = (0..area.height) + .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect()) + .collect(); + + if let Some(i) = rows.iter().position(|row| row.contains("Light Themes")) { + let below = rows.get(i + 1).map_or("", String::as_str); + assert!( + below.contains('\u{2600}'), + "height {height}: light heading at row {i} has no light entry beneath it:\n{rows:#?}" + ); + } + } + + set_theme((*previous).clone()); +} From b70c98fdf787a10093fc95a4c3d76c85345dc263 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 1 Sep 2026 23:24:49 -0700 Subject: [PATCH 12/12] chore(deny): acknowledge the ttf-parser unmaintained advisory RUSTSEC-2026-0192 marks ttf-parser as unmaintained. It reaches this crate only through the egui feature (egui 0.33 pulls epaint, ab_glyph, and owned_ttf_parser) and is a maintenance notice rather than a vulnerability. The lock file is unchanged; the advisory database moved underneath main, so cargo deny now fails on every branch. Ignore it alongside the existing time advisory, with the exit path recorded: the entry goes away when an egui release stops depending on ttf-parser. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015pQZqxAgTfPCnwGRFcVF2M --- deny.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deny.toml b/deny.toml index d3e2829..723694e 100644 --- a/deny.toml +++ b/deny.toml @@ -9,6 +9,10 @@ all-features = true ignore = [ # time 0.3.45 stack exhaustion — transitive dep from ratatui, fix requires MSRV > 1.85 "RUSTSEC-2026-0009", + # ttf-parser unmaintained notice — only reachable with the `egui` feature via + # egui 0.33 → epaint → ab_glyph → owned_ttf_parser. Drops out once egui ships + # a release that no longer depends on it. + "RUSTSEC-2026-0192", ] # ── License compliance ────────────────────────────────────────