1use crate::core::LaunchCommand;
6use crate::util::{Error, Result};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct Color {
36 pub r: u8,
38 pub g: u8,
40 pub b: u8,
42 pub a: u8,
44}
45
46impl Color {
47 pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
49 Self { r, g, b, a }
50 }
51
52 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 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 Self::new(180, 160, 255, 180) }
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#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(default)]
131pub struct Settings {
132 pub activation_key: String,
135
136 pub activation_delay: u64,
138
139 pub overlay_delay: u64,
141
142 pub quick_switch_threshold: u64,
144
145 pub border_width: f32,
147
148 pub border_color: Color,
150
151 pub background_color: Color,
153
154 pub card_color: Color,
156
157 pub text_color: Color,
159
160 pub hint_color: Color,
162
163 pub hint_matched_color: Color,
165
166 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
226#[serde(untagged)]
227pub enum LaunchConfig {
228 Simple(String),
230 Advanced {
232 command: String,
234 #[serde(default)]
236 args: Vec<String>,
237 #[serde(default)]
239 env_files: Vec<String>,
240 #[serde(default)]
242 env: HashMap<String, String>,
243 },
244}
245
246impl LaunchConfig {
247 pub fn command(&self) -> &str {
249 match self {
250 LaunchConfig::Simple(cmd) => cmd,
251 LaunchConfig::Advanced { command, .. } => command,
252 }
253 }
254
255 pub fn args(&self) -> &[String] {
257 match self {
258 LaunchConfig::Simple(_) => &[],
259 LaunchConfig::Advanced { args, .. } => args,
260 }
261 }
262
263 pub fn env_files(&self) -> &[String] {
265 match self {
266 LaunchConfig::Simple(_) => &[],
267 LaunchConfig::Advanced { env_files, .. } => env_files,
268 }
269 }
270
271 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311#[serde(default)]
312pub struct KeyBinding {
313 #[serde(default)]
315 pub apps: Vec<String>,
316
317 #[serde(default)]
319 pub launch: Option<LaunchConfig>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
349#[serde(default)]
350pub struct Config {
351 pub settings: Settings,
353
354 #[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 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 if pattern == app_id {
378 return key.chars().next();
379 }
380 if pattern.to_lowercase() == app_lower {
382 return key.chars().next();
383 }
384 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 pub fn launch_config(&self, key: &str) -> Option<&LaunchConfig> {
397 self.keys.get(key).and_then(|b| b.launch.as_ref())
398 }
399
400 pub fn to_toml(&self) -> Result<String> {
402 toml::to_string_pretty(self).map_err(|e| Error::Other(e.to_string()))
403 }
404
405 pub fn default_toml() -> String {
407 let default = Config::default();
408 toml::to_string_pretty(&default).unwrap_or_default()
409 }
410
411 pub fn load() -> Result<Self> {
413 crate::config::load_config()
414 }
415}
416
417fn 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}