diff --git a/AGENTS.md b/AGENTS.md
index e94df0a..f7dad5d 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)
```
@@ -58,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
@@ -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)
@@ -117,11 +119,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..090f9e7 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
-
+
@@ -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"]
@@ -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
```
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 ────────────────────────────────────────
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 deff5df..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?
@@ -53,9 +53,9 @@ 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 |
+| **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 06b2437..629cb8f 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" }
@@ -87,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")?;
@@ -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/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/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/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/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/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 80a110a..852547d 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
@@ -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/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/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());
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 84e1748..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()
@@ -83,13 +84,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 +99,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
},
};
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/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/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/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;
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/src/widgets/theme_selector.rs b/src/widgets/theme_selector.rs
index 0c52bc7..1d3da08 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,37 @@ fn render_theme_entries(
break;
}
+ // 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
+ // 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) {
- 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;
+ // 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",
+ };
+ 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;
- }
-
if y >= max_y {
break;
}
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 0120b52..1744927 100644
--- a/tests/builtins_tests.rs
+++ b/tests/builtins_tests.rs
@@ -1,5 +1,8 @@
+#![cfg(feature = "builtin-themes")]
+
use opaline::OpalineColor;
use opaline::builtins;
+use opaline::names::{gradients, styles, tokens};
use opaline::schema::ThemeVariant;
use pretty_assertions::assert_eq;
use std::fs;
@@ -52,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();
@@ -60,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();
@@ -93,34 +98,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 +149,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,13 +235,14 @@ 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,
];
+#[cfg(feature = "gradients")]
#[test]
fn all_builtins_have_required_gradients() {
for &(id, _) in builtins::builtin_names() {
@@ -240,6 +256,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]
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/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 2bba670..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");
@@ -54,6 +55,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");
@@ -66,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");
@@ -86,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");
@@ -121,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");
@@ -140,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/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")
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();
diff --git a/tests/theme_selector_tests.rs b/tests/theme_selector_tests.rs
index 570f52f..8763a64 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,132 @@ 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());
+}
+
+#[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());
+}