feat(lua): owlry.theme(...) + owlry.profiles {} (Phase 3.5)

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<String>  (set by string form, last-write-wins)
- theme_colors: ThemeColors   (table form, per-key last-write-wins)
- unknown_theme_keys: Vec<String> (forward-compat for 2.2+ palette)
- profiles: Option<HashMap<String, Vec<String>>>  (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.
This commit is contained in:
2026-05-13 04:43:33 +02:00
parent bfbce42eab
commit 32c6972a9e
3 changed files with 542 additions and 9 deletions
+142 -4
View File
@@ -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<Mutex<LuaConfig>>` 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<Mutex<LuaConfig>>) -> 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<Mutex<LuaConfig>>,
) -> 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<Mutex<LuaConfig>>,
) -> 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::<Option<String>>(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::<Option<String>>("badge_sys")?
{
cfg.theme_colors.badge_power = Some(v);
}
// Track unknown theme keys for `owlry config validate`.
for pair in t.pairs::<Value, Value>() {
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 <name>`. 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<String, Vec<String>> = HashMap::new();
for pair in t.pairs::<String, Table>() {
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<String> = modes_tbl
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>()
.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(())
}
+238 -5
View File
@@ -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<T>`: `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<T>`: `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<LuaProviderSpec>,
// ─── 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<String>,
/// 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<String>,
// ─── owlry.profiles { ... } ───────────────────────────────────────────
/// Named profiles from `owlry.profiles { dev = { ... }, ... }`. Each
/// profile maps to a list of provider ids used when `--profile <name>`
/// 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<HashMap<String, Vec<String>>>,
}
/// 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<String, Vec<String>> = 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"));
}
}
+162
View File
@@ -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()])
);
}
}