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 @@ docs.rs - CI + CI 39 Themes @@ -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()); +}