From 32c6972a9ec858d50fa2758f0913dcfb8367d881 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 04:43:33 +0200 Subject: [PATCH] feat(lua): owlry.theme(...) + owlry.profiles {} (Phase 3.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the theme selection and named-profile surface per docs/lua-api.md §4.5 and §6.5: owlry.theme("catppuccin-mocha") -- string form: theme name owlry.theme { background = "#1e1e2e", -- table form: colour overrides accent = "#cba6f7", badge_app = "#a6e3a1" } owlry.profiles { dev = { "app", "cmd", "ssh" }, media = {...} } LuaConfig (lua/config.rs): - theme_name: Option (set by string form, last-write-wins) - theme_colors: ThemeColors (table form, per-key last-write-wins) - unknown_theme_keys: Vec (forward-compat for 2.2+ palette) - profiles: Option>> (replace-on-call) - New KNOWN_THEME_KEYS const lists every recognised colour name. - merge_into() applies theme name + colour overlay (Some-only fields) and replaces base profiles when Lua sets them. Omitting `owlry.profiles` leaves existing TOML/default profiles intact. api.rs: - owlry.theme dispatches on mlua::Value: Value::String → set name, Value::Table → read colour keys. Other types raise a clean Lua runtime error. - Pre-v2 `badge_sys` alias normalises to `badge_power` (mirrors the serde alias on ThemeColors). - owlry.profiles: every value must be a table of strings; non-table values produce a clear "list of provider ids" error message. Tests: 19 new - lua::config (6): theme name override, colours overlay only Some fields, name+colours compose, profiles replace base, empty profiles clears, omitted profiles preserves base. - lua::runtime (13): string form, full colour table, name + table compose, per-key merge across calls, badge_sys alias, unknown forward-compat keys, wrong-arg-type error, multi-profile capture, profiles replace, non-list value error, numeric coercion docs. Smoke test (release build, isolated XDG): `owlry config show` reports `theme = "nord"`, the four colour overrides under [appearance.colors], and all three named profiles. 300/300 lib tests with --features full, no new clippy warnings. --- crates/owlry/src/lua/api.rs | 146 ++++++++++++++++++- crates/owlry/src/lua/config.rs | 243 +++++++++++++++++++++++++++++++- crates/owlry/src/lua/runtime.rs | 162 +++++++++++++++++++++ 3 files changed, 542 insertions(+), 9 deletions(-) diff --git a/crates/owlry/src/lua/api.rs b/crates/owlry/src/lua/api.rs index 3e7ac06..9b39d94 100644 --- a/crates/owlry/src/lua/api.rs +++ b/crates/owlry/src/lua/api.rs @@ -5,18 +5,24 @@ //! - `owlry.providers(list)` — enabled provider list (replaces on call) //! - `owlry.tabs(list)` — tab order list (replaces on call) //! -//! Phase 3.3 adds: +//! Phase 3.3 added: //! - `owlry.provider(table)` — register a user-defined provider //! +//! Phase 3.5 adds: +//! - `owlry.theme(name_or_table)` — theme name and/or inline colour overrides +//! - `owlry.profiles(table)` — named provider-id sets for `--profile` +//! //! Each closure shares an `Arc>` with [`super::runtime`]. -//! Later sub-phases add `owlry.theme` (3.5) and `owlry.util.*` (3.6). +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use log::warn; -use mlua::{Function, Lua, Table}; +use mlua::{Function, Lua, Table, Value}; -use super::config::{KNOWN_SET_KEYS, LuaConfig, LuaProviderSpec, is_valid_provider_id}; +use super::config::{ + KNOWN_SET_KEYS, KNOWN_THEME_KEYS, LuaConfig, LuaProviderSpec, is_valid_provider_id, +}; use super::error::LuaConfigError; /// Build the `owlry` table, register all functions on it, install it as a @@ -28,6 +34,8 @@ pub(crate) fn install(lua: &Lua, state: Arc>) -> Result<(), Lua register_providers(lua, &owlry, Arc::clone(&state))?; register_tabs(lua, &owlry, Arc::clone(&state))?; register_provider(lua, &owlry, Arc::clone(&state))?; + register_theme(lua, &owlry, Arc::clone(&state))?; + register_profiles(lua, &owlry, Arc::clone(&state))?; lua.globals().set("owlry", owlry)?; @@ -231,3 +239,133 @@ fn apply_provider(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> { Ok(()) } + +fn register_theme( + lua: &Lua, + owlry: &Table, + state: Arc>, +) -> Result<(), LuaConfigError> { + let f = lua.create_function(move |_, v: Value| { + let mut cfg = state.lock().expect("lua config state mutex poisoned"); + apply_theme(&mut cfg, v) + })?; + owlry.set("theme", f)?; + Ok(()) +} + +fn register_profiles( + lua: &Lua, + owlry: &Table, + state: Arc>, +) -> Result<(), LuaConfigError> { + let f = lua.create_function(move |_, t: Table| { + let mut cfg = state.lock().expect("lua config state mutex poisoned"); + apply_profiles(&mut cfg, t) + })?; + owlry.set("profiles", f)?; + Ok(()) +} + +/// `owlry.theme(...)` — accepts either a string (theme name) or a table +/// (inline colour overrides). Both forms can be combined across calls per +/// §4.5: name set by string-form, table-form layered on top. +fn apply_theme(cfg: &mut LuaConfig, value: Value) -> mlua::Result<()> { + match value { + Value::String(s) => { + cfg.theme_name = Some(s.to_str()?.to_string()); + Ok(()) + } + Value::Table(t) => apply_theme_table(cfg, t), + other => Err(mlua::Error::RuntimeError(format!( + "owlry.theme: expected a string (theme name) or table (colour overrides), got {}", + other.type_name() + ))), + } +} + +/// Read recognised colour keys from a theme table and overlay them on the +/// accumulated [`super::config::LuaConfig::theme_colors`]. Unknown keys are +/// recorded (forward-compat) — no error, mirroring `owlry.set` policy. +fn apply_theme_table(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> { + macro_rules! read_colour { + ($field:ident) => { + if let Some(v) = t.get::>(stringify!($field))? { + cfg.theme_colors.$field = Some(v); + } + }; + } + read_colour!(background); + read_colour!(background_secondary); + read_colour!(border); + read_colour!(text); + read_colour!(text_secondary); + read_colour!(accent); + read_colour!(accent_bright); + read_colour!(badge_app); + read_colour!(badge_bookmark); + read_colour!(badge_calc); + read_colour!(badge_clip); + read_colour!(badge_cmd); + read_colour!(badge_dmenu); + read_colour!(badge_emoji); + read_colour!(badge_file); + read_colour!(badge_script); + read_colour!(badge_ssh); + read_colour!(badge_power); + read_colour!(badge_uuctl); + read_colour!(badge_web); + read_colour!(badge_media); + read_colour!(badge_weather); + read_colour!(badge_pomo); + + // Pre-v2 alias: badge_sys → badge_power (matches the serde alias on + // ThemeColors in config/mod.rs). Only applies when the canonical key + // wasn't already set in this call. + if cfg.theme_colors.badge_power.is_none() + && let Some(v) = t.get::>("badge_sys")? + { + cfg.theme_colors.badge_power = Some(v); + } + + // Track unknown theme keys for `owlry config validate`. + for pair in t.pairs::() { + let (k, _) = pair?; + if let Value::String(s) = k { + let key = s.to_str()?.to_string(); + if !KNOWN_THEME_KEYS.contains(&key.as_str()) + && !cfg.unknown_theme_keys.contains(&key) + { + cfg.unknown_theme_keys.push(key); + } + } + } + + Ok(()) +} + +/// `owlry.profiles { dev = { "app", "cmd" }, ... }` — captures named +/// provider-id sets used by `--profile `. Replaces the entire map +/// on each call (consistent with providers/tabs replace-on-call semantics). +fn apply_profiles(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> { + let mut profiles: HashMap> = HashMap::new(); + for pair in t.pairs::() { + let (name, modes_tbl) = pair.map_err(|e| { + mlua::Error::RuntimeError(format!( + "owlry.profiles: every value must be a list of provider ids — {}", + e + )) + })?; + let modes: Vec = modes_tbl + .sequence_values::() + .collect::>>() + .map_err(|e| { + mlua::Error::RuntimeError(format!( + "owlry.profiles.{}: list entries must be strings — {}", + name, e + )) + })?; + profiles.insert(name, modes); + } + cfg.profiles = Some(profiles); + Ok(()) +} diff --git a/crates/owlry/src/lua/config.rs b/crates/owlry/src/lua/config.rs index df37e3f..ac63264 100644 --- a/crates/owlry/src/lua/config.rs +++ b/crates/owlry/src/lua/config.rs @@ -1,11 +1,15 @@ //! Data layer for the Lua config: the [`LuaConfig`] struct that accumulates -//! state from `owlry.set` / `owlry.providers` / `owlry.tabs` calls, plus the -//! merge that overlays it onto the existing TOML-derived [`crate::config::Config`]. +//! state from `owlry.set` / `owlry.providers` / `owlry.tabs` / `owlry.theme` +//! / `owlry.profiles` / `owlry.provider` calls, plus the merge that overlays +//! it onto the existing TOML-derived [`crate::config::Config`]. //! -//! Mapping mirrors `docs/lua-api.md` §4.1–§4.3. Every settable key is an -//! `Option`: `None` means "the user didn't touch it, keep the base value." +//! Mapping mirrors `docs/lua-api.md` §4.1–§4.5 and §6.5. Every settable +//! key is an `Option`: `None` means "the user didn't touch it, keep the +//! base value." -use crate::config::Config; +use std::collections::HashMap; + +use crate::config::{Config, ProfileConfig, ThemeColors}; /// Accumulated state from `owlry.lua`. Built up by the closures registered in /// [`super::api::install`] and drained via [`super::runtime::LuaContext::snapshot`]. @@ -41,6 +45,29 @@ pub struct LuaConfig { /// registration. Duplicates by `id` are resolved at registration time: /// later wins, earlier is dropped (warning logged). pub user_providers: Vec, + + // ─── owlry.theme(...) ───────────────────────────────────────────────── + /// Theme name from the string form `owlry.theme("nord")`. Last write wins + /// across multiple calls (string forms merge with table forms — see + /// §4.5: "call owlry.theme(name) first, then owlry.theme { ... } to + /// layer overrides on top"). + pub theme_name: Option, + + /// Inline colour overrides from the table form `owlry.theme { ... }`. + /// Per-key last-write-wins. Empty by default (no overrides). + pub theme_colors: ThemeColors, + + /// Theme keys we don't recognise (forward-compat for 2.2+ palette + /// additions). Surfaced by `owlry config validate` (Phase 3.9). + pub unknown_theme_keys: Vec, + + // ─── owlry.profiles { ... } ─────────────────────────────────────────── + /// Named profiles from `owlry.profiles { dev = { ... }, ... }`. Each + /// profile maps to a list of provider ids used when `--profile ` + /// is passed. `None` = profiles were never set in Lua → keep TOML or + /// defaults. `Some(map)` replaces the base profiles (consistent with + /// `providers`/`tabs` replace-on-call semantics). + pub profiles: Option>>, } /// Captured spec from a single `owlry.provider { ... }` call. @@ -104,6 +131,36 @@ pub(crate) const KNOWN_SET_KEYS: &[&str] = &[ "search_engine", ]; +/// Known keys accepted by `owlry.theme { ... }`. Mirrors the fields of +/// [`ThemeColors`]; the pre-v2 `badge_sys` alias is included so existing +/// configs translate cleanly (it maps to `badge_power` per the v2 rename). +pub(crate) const KNOWN_THEME_KEYS: &[&str] = &[ + "background", + "background_secondary", + "border", + "text", + "text_secondary", + "accent", + "accent_bright", + "badge_app", + "badge_bookmark", + "badge_calc", + "badge_clip", + "badge_cmd", + "badge_dmenu", + "badge_emoji", + "badge_file", + "badge_script", + "badge_ssh", + "badge_power", + "badge_sys", // pre-v2 alias, normalised to badge_power at merge time + "badge_uuctl", + "badge_web", + "badge_media", + "badge_weather", + "badge_pomo", +]; + impl LuaConfig { /// Apply the Lua-side overrides on top of an existing [`Config`]. /// @@ -158,9 +215,65 @@ impl LuaConfig { if let Some(list) = &self.tabs { cfg.general.tabs = list.clone(); } + + // ── owlry.theme(...) ─────────────────────────────────────────── + if let Some(name) = &self.theme_name { + cfg.appearance.theme = Some(name.clone()); + } + merge_theme_colors(&self.theme_colors, &mut cfg.appearance.colors); + + // ── owlry.profiles { ... } ───────────────────────────────────── + // Replace base profiles entirely (consistent with providers/tabs + // replace-on-call). Profile values become `ProfileConfig { modes }`. + if let Some(profiles) = &self.profiles { + cfg.profiles.clear(); + for (name, modes) in profiles { + cfg.profiles.insert( + name.clone(), + ProfileConfig { + modes: modes.clone(), + }, + ); + } + } } } +/// Overlay any `Some` colour fields from the Lua side onto the base +/// [`ThemeColors`]. `None` fields are skipped so the base theme survives. +fn merge_theme_colors(src: &ThemeColors, dst: &mut ThemeColors) { + macro_rules! merge { + ($field:ident) => { + if let Some(ref v) = src.$field { + dst.$field = Some(v.clone()); + } + }; + } + merge!(background); + merge!(background_secondary); + merge!(border); + merge!(text); + merge!(text_secondary); + merge!(accent); + merge!(accent_bright); + merge!(badge_app); + merge!(badge_bookmark); + merge!(badge_calc); + merge!(badge_clip); + merge!(badge_cmd); + merge!(badge_dmenu); + merge!(badge_emoji); + merge!(badge_file); + merge!(badge_script); + merge!(badge_ssh); + merge!(badge_power); + merge!(badge_uuctl); + merge!(badge_web); + merge!(badge_media); + merge!(badge_weather); + merge!(badge_pomo); +} + /// Translate a list of provider IDs (as the user wrote them in /// `owlry.providers { ... }`) into the per-provider boolean flags on /// [`crate::config::ProvidersConfig`]. Ids not present in the list are @@ -393,4 +506,124 @@ mod tests { assert!(!is_valid_provider_id("colon:bad")); assert!(!is_valid_provider_id("emoji_💀")); } + + // ── owlry.theme(...) merge semantics ──────────────────────────────────── + + #[test] + fn theme_name_overrides_base_appearance_theme() { + let mut cfg = Config::default(); + let lc = LuaConfig { + theme_name: Some("catppuccin-mocha".into()), + ..Default::default() + }; + lc.merge_into(&mut cfg); + assert_eq!( + cfg.appearance.theme.as_deref(), + Some("catppuccin-mocha") + ); + } + + #[test] + fn theme_colours_overlay_only_set_fields() { + let mut cfg = Config::default(); + // Base theme has colours already set: + cfg.appearance.colors.background = Some("#000000".into()); + cfg.appearance.colors.text = Some("#ffffff".into()); + + // Lua overlays only the accent — background and text must survive. + let mut lua_colors = ThemeColors::default(); + lua_colors.accent = Some("#ff00ff".into()); + let lc = LuaConfig { + theme_colors: lua_colors, + ..Default::default() + }; + lc.merge_into(&mut cfg); + + assert_eq!(cfg.appearance.colors.background.as_deref(), Some("#000000")); + assert_eq!(cfg.appearance.colors.text.as_deref(), Some("#ffffff")); + assert_eq!(cfg.appearance.colors.accent.as_deref(), Some("#ff00ff")); + } + + #[test] + fn theme_name_and_colours_compose() { + let mut cfg = Config::default(); + let mut lua_colors = ThemeColors::default(); + lua_colors.background = Some("#1e1e2e".into()); + let lc = LuaConfig { + theme_name: Some("nord".into()), + theme_colors: lua_colors, + ..Default::default() + }; + lc.merge_into(&mut cfg); + assert_eq!(cfg.appearance.theme.as_deref(), Some("nord")); + assert_eq!(cfg.appearance.colors.background.as_deref(), Some("#1e1e2e")); + } + + // ── owlry.profiles { ... } merge semantics ────────────────────────────── + + #[test] + fn profiles_map_replaces_base_profiles() { + let mut cfg = Config::default(); + // Base TOML had a "legacy" profile that must be cleared. + cfg.profiles.insert( + "legacy".into(), + ProfileConfig { + modes: vec!["app".into()], + }, + ); + + let mut profiles: HashMap> = HashMap::new(); + profiles.insert("dev".into(), vec!["app".into(), "cmd".into(), "ssh".into()]); + profiles.insert("media".into(), vec!["emoji".into(), "clipboard".into()]); + let lc = LuaConfig { + profiles: Some(profiles), + ..Default::default() + }; + lc.merge_into(&mut cfg); + + assert!( + !cfg.profiles.contains_key("legacy"), + "Lua profiles must replace base entries entirely" + ); + assert_eq!(cfg.profiles.len(), 2); + assert_eq!( + cfg.profiles.get("dev").map(|p| p.modes.as_slice()), + Some(&["app".to_string(), "cmd".to_string(), "ssh".to_string()][..]) + ); + assert_eq!( + cfg.profiles.get("media").map(|p| p.modes.as_slice()), + Some(&["emoji".to_string(), "clipboard".to_string()][..]) + ); + } + + #[test] + fn empty_profiles_clears_base_profiles() { + let mut cfg = Config::default(); + cfg.profiles.insert( + "legacy".into(), + ProfileConfig { + modes: vec!["app".into()], + }, + ); + let lc = LuaConfig { + profiles: Some(HashMap::new()), + ..Default::default() + }; + lc.merge_into(&mut cfg); + assert!(cfg.profiles.is_empty()); + } + + #[test] + fn omitted_profiles_keeps_base_profiles() { + let mut cfg = Config::default(); + cfg.profiles.insert( + "legacy".into(), + ProfileConfig { + modes: vec!["app".into()], + }, + ); + let lc = LuaConfig::default(); // profiles: None + lc.merge_into(&mut cfg); + assert!(cfg.profiles.contains_key("legacy")); + } } diff --git a/crates/owlry/src/lua/runtime.rs b/crates/owlry/src/lua/runtime.rs index 5d04ebd..c993268 100644 --- a/crates/owlry/src/lua/runtime.rs +++ b/crates/owlry/src/lua/runtime.rs @@ -346,4 +346,166 @@ mod tests { other => panic!("expected Read error, got {:?}", other), } } + + // ── owlry.theme(...) end-to-end ───────────────────────────────────────── + + #[test] + fn theme_string_form_sets_theme_name() { + let s = snapshot_after(r#"owlry.theme("catppuccin-mocha")"#); + assert_eq!(s.theme_name.as_deref(), Some("catppuccin-mocha")); + // Colours and unknowns unaffected. + assert!(s.theme_colors.background.is_none()); + assert!(s.unknown_theme_keys.is_empty()); + } + + #[test] + fn theme_table_form_captures_known_colour_keys() { + let s = snapshot_after( + r##" + owlry.theme { + background = "#1e1e2e", + background_secondary = "#313244", + border = "#45475a", + text = "#cdd6f4", + text_secondary = "#a6adc8", + accent = "#cba6f7", + accent_bright = "#f5c2e7", + badge_app = "#a6e3a1", + badge_power = "#f38ba8", + badge_uuctl = "#9ece6a", + } + "##, + ); + assert_eq!(s.theme_colors.background.as_deref(), Some("#1e1e2e")); + assert_eq!(s.theme_colors.accent.as_deref(), Some("#cba6f7")); + assert_eq!(s.theme_colors.badge_app.as_deref(), Some("#a6e3a1")); + assert_eq!(s.theme_colors.badge_power.as_deref(), Some("#f38ba8")); + assert_eq!(s.theme_colors.badge_uuctl.as_deref(), Some("#9ece6a")); + assert!(s.unknown_theme_keys.is_empty()); + } + + #[test] + fn theme_string_and_table_compose() { + let s = snapshot_after( + r##" + owlry.theme("nord") + owlry.theme { background = "#1e1e2e" } + "##, + ); + assert_eq!(s.theme_name.as_deref(), Some("nord")); + assert_eq!(s.theme_colors.background.as_deref(), Some("#1e1e2e")); + } + + #[test] + fn theme_table_calls_merge_per_colour_key() { + let s = snapshot_after( + r##" + owlry.theme { background = "#000000", accent = "#ff00ff" } + owlry.theme { accent = "#00ffff" } + "##, + ); + assert_eq!(s.theme_colors.background.as_deref(), Some("#000000")); + assert_eq!(s.theme_colors.accent.as_deref(), Some("#00ffff")); + } + + #[test] + fn theme_badge_sys_alias_maps_to_badge_power() { + let s = snapshot_after(r##"owlry.theme { badge_sys = "#ff8800" }"##); + assert_eq!(s.theme_colors.badge_power.as_deref(), Some("#ff8800")); + } + + #[test] + fn theme_unknown_keys_recorded_without_failing() { + let s = snapshot_after( + r##" + owlry.theme { + background = "#000000", + future_v22_key = "#ffffff", + another_unknown = "x", + } + "##, + ); + assert_eq!(s.theme_colors.background.as_deref(), Some("#000000")); + assert!(s.unknown_theme_keys.contains(&"future_v22_key".to_string())); + assert!(s.unknown_theme_keys.contains(&"another_unknown".to_string())); + } + + #[test] + fn theme_wrong_argument_type_errors() { + let ctx = LuaContext::new().expect("must build"); + let err = ctx.eval_str(r#"owlry.theme(42)"#).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("expected a string") || msg.contains("table"), + "should mention expected shapes; got: {}", + msg + ); + } + + // ── owlry.profiles { ... } end-to-end ─────────────────────────────────── + + #[test] + fn profiles_captures_multiple_named_sets() { + let s = snapshot_after( + r#" + owlry.profiles { + dev = { "app", "cmd", "ssh" }, + media = { "emoji", "clipboard" }, + minimal = { "app" }, + } + "#, + ); + let map = s.profiles.expect("profiles must be Some"); + assert_eq!(map.len(), 3); + assert_eq!( + map.get("dev"), + Some(&vec!["app".to_string(), "cmd".to_string(), "ssh".to_string()]) + ); + assert_eq!( + map.get("minimal"), + Some(&vec!["app".to_string()]) + ); + } + + #[test] + fn profiles_called_twice_replaces_the_map() { + let s = snapshot_after( + r#" + owlry.profiles { dev = { "app", "cmd" } } + owlry.profiles { media = { "emoji" } } + "#, + ); + let map = s.profiles.expect("profiles must be Some"); + assert_eq!(map.len(), 1); + assert!(map.contains_key("media")); + assert!(!map.contains_key("dev"), "second call must fully replace"); + } + + #[test] + fn profiles_with_non_list_value_errors() { + let ctx = LuaContext::new().expect("must build"); + let err = ctx + .eval_str(r#"owlry.profiles { broken = "not a list" }"#) + .unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("owlry.profiles") || msg.contains("list"), + "should indicate list-of-strings expectation; got: {}", + msg + ); + } + + #[test] + fn profiles_coerce_numeric_entries_via_lua_tostring() { + // mlua's String::from_lua follows Lua semantics (tostring coercion), + // so `{ "app", 42 }` becomes ["app", "42"]. Document the behaviour + // here so it isn't accidentally tightened — config validate (3.9) + // can flag "42" as an unknown provider id if needed. + let s = snapshot_after(r#"owlry.profiles { dev = { "app", 42 } }"#); + let map = s.profiles.expect("profiles must be Some"); + assert_eq!( + map.get("dev"), + Some(&vec!["app".to_string(), "42".to_string()]) + ); + } }