open_sesame/config/
schema.rs

1//! Configuration schema types
2//!
3//! Pure data types with no business logic.
4
5use crate::core::LaunchCommand;
6use crate::util::{Error, Result};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use std::collections::HashMap;
9
10/// RGBA color with hex string serialization
11///
12/// Supports parsing from hex strings ("#RRGGBB" or "#RRGGBBAA") and serialization back to hex.
13///
14/// # Examples
15///
16/// ```
17/// use open_sesame::config::Color;
18///
19/// // Parse from hex string
20/// let color = Color::from_hex("#ff0000").unwrap();
21/// assert_eq!(color.r, 255);
22/// assert_eq!(color.g, 0);
23/// assert_eq!(color.b, 0);
24/// assert_eq!(color.a, 255);
25///
26/// // Create from components
27/// let purple = Color::new(180, 160, 255, 180);
28/// assert_eq!(purple.to_hex(), "#b4a0ffb4");
29///
30/// // Parse with alpha
31/// let translucent = Color::from_hex("#00ff00b4").unwrap();
32/// assert_eq!(translucent.a, 180);
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct Color {
36    /// Red channel (0-255)
37    pub r: u8,
38    /// Green channel (0-255)
39    pub g: u8,
40    /// Blue channel (0-255)
41    pub b: u8,
42    /// Alpha channel (0-255, where 255 is fully opaque)
43    pub a: u8,
44}
45
46impl Color {
47    /// Create a new color from RGBA components
48    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
49        Self { r, g, b, a }
50    }
51
52    /// Parses a color from hex string: "#RRGGBB" or "#RRGGBBAA".
53    pub fn from_hex(s: &str) -> Result<Self> {
54        let s = s.trim_start_matches('#');
55        match s.len() {
56            6 => {
57                let r = u8::from_str_radix(&s[0..2], 16).map_err(|_| Error::InvalidColor {
58                    value: s.to_string(),
59                })?;
60                let g = u8::from_str_radix(&s[2..4], 16).map_err(|_| Error::InvalidColor {
61                    value: s.to_string(),
62                })?;
63                let b = u8::from_str_radix(&s[4..6], 16).map_err(|_| Error::InvalidColor {
64                    value: s.to_string(),
65                })?;
66                Ok(Self { r, g, b, a: 255 })
67            }
68            8 => {
69                let r = u8::from_str_radix(&s[0..2], 16).map_err(|_| Error::InvalidColor {
70                    value: s.to_string(),
71                })?;
72                let g = u8::from_str_radix(&s[2..4], 16).map_err(|_| Error::InvalidColor {
73                    value: s.to_string(),
74                })?;
75                let b = u8::from_str_radix(&s[4..6], 16).map_err(|_| Error::InvalidColor {
76                    value: s.to_string(),
77                })?;
78                let a = u8::from_str_radix(&s[6..8], 16).map_err(|_| Error::InvalidColor {
79                    value: s.to_string(),
80                })?;
81                Ok(Self { r, g, b, a })
82            }
83            _ => Err(Error::InvalidColor {
84                value: s.to_string(),
85            }),
86        }
87    }
88
89    /// Converts the color to a hex string with alpha channel.
90    pub fn to_hex(self) -> String {
91        format!("#{:02x}{:02x}{:02x}{:02x}", self.r, self.g, self.b, self.a)
92    }
93}
94
95impl Default for Color {
96    fn default() -> Self {
97        // Soft lavender-purple with ~70% opacity
98        Self::new(180, 160, 255, 180) // #b4a0ffb4
99    }
100}
101
102impl Serialize for Color {
103    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
104        serializer.serialize_str(&(*self).to_hex())
105    }
106}
107
108impl<'de> Deserialize<'de> for Color {
109    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
110        let s = String::deserialize(deserializer)?;
111        Self::from_hex(&s).map_err(serde::de::Error::custom)
112    }
113}
114
115/// Global settings for timing and appearance
116///
117/// Controls activation delays, UI appearance, and global environment variables.
118///
119/// # Examples
120///
121/// ```
122/// use open_sesame::config::Settings;
123///
124/// let settings = Settings::default();
125/// assert_eq!(settings.activation_delay, 200);
126/// assert_eq!(settings.overlay_delay, 720);
127/// assert_eq!(settings.quick_switch_threshold, 250);
128/// ```
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(default)]
131pub struct Settings {
132    /// Activation key combo for launching sesame (e.g., "super+space", "alt+tab")
133    /// Used by --setup-keybinding to configure COSMIC shortcuts
134    pub activation_key: String,
135
136    /// Delay in ms before activating a match (allows typing gg, ggg)
137    pub activation_delay: u64,
138
139    /// Delay in ms before showing full overlay (0 = immediate)
140    pub overlay_delay: u64,
141
142    /// Quick switch threshold in ms - Alt+Tab released within this time = instant switch to previous window
143    pub quick_switch_threshold: u64,
144
145    /// Border width in pixels for focus indicator
146    pub border_width: f32,
147
148    /// Border color for focus indicator (hex: "#RRGGBB" or "#RRGGBBAA")
149    pub border_color: Color,
150
151    /// Background overlay color
152    pub background_color: Color,
153
154    /// Card background color
155    pub card_color: Color,
156
157    /// Text color
158    pub text_color: Color,
159
160    /// Hint badge color
161    pub hint_color: Color,
162
163    /// Matched hint color
164    pub hint_matched_color: Color,
165
166    /// Global env files loaded for all launches (direnv .env style)
167    #[serde(default)]
168    pub env_files: Vec<String>,
169}
170
171impl Default for Settings {
172    fn default() -> Self {
173        Self {
174            activation_key: "alt+space".to_string(),
175            activation_delay: 200,
176            overlay_delay: 720,
177            quick_switch_threshold: 250,
178            border_width: 3.0,
179            border_color: Color::default(),
180            background_color: Color::new(0, 0, 0, 200),
181            card_color: Color::new(30, 30, 30, 240),
182            text_color: Color::new(255, 255, 255, 255),
183            hint_color: Color::new(100, 100, 100, 255),
184            hint_matched_color: Color::new(76, 175, 80, 255),
185            env_files: Vec::new(),
186        }
187    }
188}
189
190/// Launch configuration - supports simple command string or advanced config
191///
192/// Provides two forms: simple (just a command string) and advanced (with args, env files, and env vars).
193///
194/// # Examples
195///
196/// ## Simple Launch
197///
198/// ```
199/// use open_sesame::config::LaunchConfig;
200///
201/// let simple = LaunchConfig::Simple("firefox".to_string());
202/// assert_eq!(simple.command(), "firefox");
203/// assert!(simple.args().is_empty());
204/// ```
205///
206/// ## Advanced Launch
207///
208/// ```
209/// use open_sesame::config::LaunchConfig;
210/// use std::collections::HashMap;
211///
212/// let mut env = HashMap::new();
213/// env.insert("EDITOR".to_string(), "vim".to_string());
214///
215/// let advanced = LaunchConfig::Advanced {
216///     command: "ghostty".to_string(),
217///     args: vec!["--config".to_string(), "custom.toml".to_string()],
218///     env_files: vec!["~/.config/ghostty/.env".to_string()],
219///     env,
220/// };
221///
222/// assert_eq!(advanced.command(), "ghostty");
223/// assert_eq!(advanced.args().len(), 2);
224/// ```
225#[derive(Debug, Clone, Serialize, Deserialize)]
226#[serde(untagged)]
227pub enum LaunchConfig {
228    /// Simple command: `launch = "ghostty"`
229    Simple(String),
230    /// Advanced config with args and env
231    Advanced {
232        /// Command to run (binary name or full path)
233        command: String,
234        /// Arguments to pass to the command
235        #[serde(default)]
236        args: Vec<String>,
237        /// Env files to load (direnv .env style, paths expanded)
238        #[serde(default)]
239        env_files: Vec<String>,
240        /// Environment variables to set for the process
241        #[serde(default)]
242        env: HashMap<String, String>,
243    },
244}
245
246impl LaunchConfig {
247    /// Returns the command to execute.
248    pub fn command(&self) -> &str {
249        match self {
250            LaunchConfig::Simple(cmd) => cmd,
251            LaunchConfig::Advanced { command, .. } => command,
252        }
253    }
254
255    /// Returns command arguments (empty for simple config).
256    pub fn args(&self) -> &[String] {
257        match self {
258            LaunchConfig::Simple(_) => &[],
259            LaunchConfig::Advanced { args, .. } => args,
260        }
261    }
262
263    /// Returns environment files to load (empty for simple config).
264    pub fn env_files(&self) -> &[String] {
265        match self {
266            LaunchConfig::Simple(_) => &[],
267            LaunchConfig::Advanced { env_files, .. } => env_files,
268        }
269    }
270
271    /// Returns explicit environment variables.
272    pub fn env(&self) -> HashMap<String, String> {
273        match self {
274            LaunchConfig::Simple(_) => HashMap::new(),
275            LaunchConfig::Advanced { env, .. } => env.clone(),
276        }
277    }
278
279    /// Converts to a LaunchCommand for execution.
280    pub fn to_launch_command(&self) -> LaunchCommand {
281        match self {
282            LaunchConfig::Simple(cmd) => LaunchCommand::simple(cmd),
283            LaunchConfig::Advanced {
284                command,
285                args,
286                env_files,
287                env,
288            } => LaunchCommand::advanced(command, args.clone(), env_files.clone(), env.clone()),
289        }
290    }
291}
292
293/// Configuration for a single key binding
294///
295/// Associates a key with application IDs and an optional launch command.
296///
297/// # Examples
298///
299/// ```
300/// use open_sesame::config::{KeyBinding, LaunchConfig};
301///
302/// let binding = KeyBinding {
303///     apps: vec!["firefox".to_string(), "org.mozilla.firefox".to_string()],
304///     launch: Some(LaunchConfig::Simple("firefox".to_string())),
305/// };
306///
307/// assert_eq!(binding.apps.len(), 2);
308/// assert!(binding.launch.is_some());
309/// ```
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311#[serde(default)]
312pub struct KeyBinding {
313    /// App IDs that match this key
314    #[serde(default)]
315    pub apps: Vec<String>,
316
317    /// Launch config if no matching window exists
318    #[serde(default)]
319    pub launch: Option<LaunchConfig>,
320}
321
322/// Main configuration structure
323///
324/// Provides global settings and per-key bindings for application focus/launch behavior.
325///
326/// # Examples
327///
328/// ```no_run
329/// use open_sesame::Config;
330///
331/// # fn main() -> Result<(), open_sesame::Error> {
332/// // Load from default XDG paths
333/// let config = Config::load()?;
334/// println!("Activation key: {}", config.settings.activation_key);
335///
336/// // Check key binding for an application
337/// if let Some(key) = config.key_for_app("firefox") {
338///     println!("Firefox is bound to: {}", key);
339/// }
340///
341/// // Get launch command for a key
342/// if let Some(launch) = config.launch_config("g") {
343///     println!("Key 'g' launches: {}", launch.command());
344/// }
345/// # Ok(())
346/// # }
347/// ```
348#[derive(Debug, Clone, Serialize, Deserialize)]
349#[serde(default)]
350pub struct Config {
351    /// Global settings
352    pub settings: Settings,
353
354    /// Key bindings: letter -> binding config
355    #[serde(default)]
356    pub keys: HashMap<String, KeyBinding>,
357}
358
359impl Default for Config {
360    fn default() -> Self {
361        Self {
362            settings: Settings::default(),
363            keys: default_keys(),
364        }
365    }
366}
367
368impl Config {
369    /// Returns the key binding character for an app_id.
370    pub fn key_for_app(&self, app_id: &str) -> Option<char> {
371        let app_lower = app_id.to_lowercase();
372        let app_last_segment = app_id.split('.').next_back().map(|s| s.to_lowercase());
373
374        for (key, binding) in &self.keys {
375            for pattern in &binding.apps {
376                // Exact match
377                if pattern == app_id {
378                    return key.chars().next();
379                }
380                // Case-insensitive match
381                if pattern.to_lowercase() == app_lower {
382                    return key.chars().next();
383                }
384                // Last segment match (e.g., "ghostty" matches "com.mitchellh.ghostty")
385                if let Some(ref last) = app_last_segment
386                    && pattern.to_lowercase() == *last
387                {
388                    return key.chars().next();
389                }
390            }
391        }
392        None
393    }
394
395    /// Returns the launch config for a key.
396    pub fn launch_config(&self, key: &str) -> Option<&LaunchConfig> {
397        self.keys.get(key).and_then(|b| b.launch.as_ref())
398    }
399
400    /// Serializes configuration to TOML string.
401    pub fn to_toml(&self) -> Result<String> {
402        toml::to_string_pretty(self).map_err(|e| Error::Other(e.to_string()))
403    }
404
405    /// Generates default TOML configuration string.
406    pub fn default_toml() -> String {
407        let default = Config::default();
408        toml::to_string_pretty(&default).unwrap_or_default()
409    }
410
411    /// Loads configuration from default XDG paths.
412    pub fn load() -> Result<Self> {
413        crate::config::load_config()
414    }
415}
416
417/// Generates default key bindings.
418fn default_keys() -> HashMap<String, KeyBinding> {
419    [
420        (
421            "g",
422            &["ghostty", "com.mitchellh.ghostty"][..],
423            Some("ghostty"),
424        ),
425        (
426            "f",
427            &["firefox", "org.mozilla.firefox"][..],
428            Some("firefox"),
429        ),
430        ("e", &["microsoft-edge"][..], Some("microsoft-edge")),
431        ("c", &["chromium", "google-chrome"][..], None),
432        ("v", &["code", "Code", "cursor", "Cursor"][..], Some("code")),
433        (
434            "n",
435            &["nautilus", "org.gnome.Nautilus"][..],
436            Some("nautilus"),
437        ),
438        ("s", &["slack", "Slack"][..], Some("slack")),
439        ("d", &["discord", "Discord"][..], Some("discord")),
440        ("m", &["spotify"][..], Some("spotify")),
441        ("t", &["thunderbird"][..], Some("thunderbird")),
442    ]
443    .into_iter()
444    .map(|(key, apps, launch)| {
445        (
446            key.to_string(),
447            KeyBinding {
448                apps: apps.iter().map(|s| s.to_string()).collect(),
449                launch: launch.map(|cmd| LaunchConfig::Simple(cmd.to_string())),
450            },
451        )
452    })
453    .collect()
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn test_color_hex_parse() {
462        let c = Color::from_hex("#ff0000").unwrap();
463        assert_eq!(c, Color::new(255, 0, 0, 255));
464
465        let c = Color::from_hex("#00ff00ff").unwrap();
466        assert_eq!(c, Color::new(0, 255, 0, 255));
467
468        let c = Color::from_hex("63a4ffb4").unwrap();
469        assert_eq!(c, Color::new(99, 164, 255, 180));
470    }
471
472    #[test]
473    fn test_color_hex_roundtrip() {
474        let c = Color::new(99, 164, 255, 180);
475        assert_eq!(c.to_hex(), "#63a4ffb4");
476        assert_eq!(Color::from_hex(&c.to_hex()).unwrap(), c);
477    }
478
479    #[test]
480    fn test_default_config() {
481        let config = Config::default();
482        assert!(!config.keys.is_empty());
483        assert_eq!(config.settings.activation_delay, 200);
484    }
485
486    #[test]
487    fn test_key_for_app() {
488        let config = Config::default();
489        assert_eq!(config.key_for_app("ghostty"), Some('g'));
490        assert_eq!(config.key_for_app("com.mitchellh.ghostty"), Some('g'));
491        assert_eq!(config.key_for_app("Ghostty"), Some('g'));
492        assert_eq!(config.key_for_app("unknown-app"), None);
493    }
494
495    #[test]
496    fn test_launch_config_simple() {
497        let config = Config::default();
498        let launch = config.launch_config("g").unwrap();
499        assert_eq!(launch.command(), "ghostty");
500        assert!(launch.args().is_empty());
501    }
502}