use crate::theme::Palette;
use ratatui::style::Color;
use serde::Deserialize;
use std::io::{IsTerminal, Read, Write};
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
#[derive(Clone, Copy, Debug)]
pub struct Config {
pub palette: Palette,
pub icons: IconMode,
pub layout: Layout,
pub git: bool,
pub mouse: bool,
pub animate: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum IconMode {
#[default]
Unicode,
Nerd,
None,
}
impl FromStr for IconMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"unicode" => Ok(IconMode::Unicode),
"nerd" => Ok(IconMode::Nerd),
"none" => Ok(IconMode::None),
_ => Err(()),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Layout {
#[default]
Auto,
Miller,
Double,
}
impl FromStr for Layout {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"auto" => Ok(Layout::Auto),
"miller" => Ok(Layout::Miller),
"double" => Ok(Layout::Double),
_ => Err(()),
}
}
}
impl Layout {
pub fn cycle(self) -> Self {
match self {
Layout::Auto => Layout::Miller,
Layout::Miller => Layout::Double,
Layout::Double => Layout::Auto,
}
}
}
#[derive(Deserialize, Default)]
struct FileConfig {
theme: Option<String>,
icons: Option<String>,
layout: Option<String>,
git: Option<bool>,
mouse: Option<bool>,
animate: Option<bool>,
colors: Option<std::collections::HashMap<String, String>>,
}
pub fn load(
cli_theme: Option<String>,
cli_icons: Option<String>,
cli_layout: Option<String>,
cli_git: Option<bool>,
cli_mouse: Option<bool>,
cli_animate: Option<bool>,
) -> Config {
let file = read_file_config().unwrap_or_default();
let env_theme = std::env::var("SUCHER_THEME").ok();
let env_icons = std::env::var("SUCHER_ICONS").ok();
let env_layout = std::env::var("SUCHER_LAYOUT").ok();
let env_git = std::env::var("SUCHER_GIT").ok();
let env_mouse = std::env::var("SUCHER_MOUSE").ok();
let env_animate = std::env::var("SUCHER_ANIMATE").ok();
let theme_name = resolve_theme_name(cli_theme, env_theme, file.theme.clone());
let icons = resolve_icons(cli_icons, env_icons, file.icons.clone());
let layout = resolve_layout(cli_layout, env_layout, file.layout.clone());
let git = resolve_git(cli_git, env_git, file.git);
let mouse = resolve_mouse(cli_mouse, env_mouse, file.mouse);
let animate = resolve_animate(cli_animate, env_animate, file.animate);
let mut palette = resolve_palette(&theme_name);
apply_color_overrides(&mut palette, file.colors.as_ref());
Config {
palette,
icons,
layout,
git,
mouse,
animate,
}
}
fn resolve_theme_name(cli: Option<String>, env: Option<String>, file: Option<String>) -> String {
cli.or(env)
.or(file)
.unwrap_or_else(|| "sucher-dark".to_string())
}
fn resolve_icons(cli: Option<String>, env: Option<String>, file: Option<String>) -> IconMode {
cli.or(env)
.or(file)
.and_then(|s| IconMode::from_str(&s).ok())
.unwrap_or_default()
}
fn resolve_layout(cli: Option<String>, env: Option<String>, file: Option<String>) -> Layout {
cli.or(env)
.or(file)
.and_then(|s| Layout::from_str(&s).ok())
.unwrap_or_default()
}
fn resolve_git(cli: Option<bool>, env: Option<String>, file: Option<bool>) -> bool {
if let Some(c) = cli {
return c;
}
if let Some(b) = env.as_deref().and_then(parse_bool) {
return b;
}
file.unwrap_or(true)
}
fn resolve_mouse(cli: Option<bool>, env: Option<String>, file: Option<bool>) -> bool {
if let Some(c) = cli {
return c;
}
if let Some(b) = env.as_deref().and_then(parse_bool) {
return b;
}
file.unwrap_or(true)
}
fn resolve_animate(cli: Option<bool>, env: Option<String>, file: Option<bool>) -> bool {
if let Some(c) = cli {
return c;
}
if let Some(b) = env.as_deref().and_then(parse_bool) {
return b;
}
file.unwrap_or(true)
}
fn parse_bool(s: &str) -> Option<bool> {
match s.trim().to_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn resolve_palette(name: &str) -> Palette {
if name == "auto" {
return if detect_dark() {
Palette::sucher_dark()
} else {
Palette::sucher_light()
};
}
Palette::by_name(name).unwrap_or_else(Palette::sucher_dark)
}
fn apply_color_overrides(
p: &mut Palette,
colors: Option<&std::collections::HashMap<String, String>>,
) {
let Some(colors) = colors else { return };
for (key, val) in colors {
let Some(c) = parse_hex(val) else { continue };
match key.as_str() {
"dir" => p.dir = c,
"image" => p.image = c,
"video" => p.video = c,
"pdf" => p.pdf = c,
"sheet" => p.sheet = c,
"doc" => p.doc = c,
"code" => p.code = c,
"archive" => p.archive = c,
"other" => p.other = c,
"dim" => p.dim = c,
"accent" => p.accent = c,
"selection" => p.selection = c,
"keyword" => p.keyword = c,
_ => {}
}
}
}
fn read_file_config() -> Option<FileConfig> {
let path = config_path()?;
let text = std::fs::read_to_string(path).ok()?;
toml::from_str(&text).ok()
}
fn config_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))?;
Some(base.join("sucher").join("config.toml"))
}
fn parse_hex(s: &str) -> Option<Color> {
let h = s.trim().strip_prefix('#').unwrap_or(s.trim());
if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&h[0..2], 16).ok()?;
let g = u8::from_str_radix(&h[2..4], 16).ok()?;
let b = u8::from_str_radix(&h[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
pub fn is_dark(r: u8, g: u8, b: u8) -> bool {
let luma = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
luma < 128.0
}
pub fn detect_dark() -> bool {
if let Some(dark) = from_colorfgbg() {
return dark;
}
if let Some((r, g, b)) = query_osc11() {
return is_dark(r, g, b);
}
true
}
fn from_colorfgbg() -> Option<bool> {
let raw = std::env::var("COLORFGBG").ok()?;
let bg = raw.rsplit(';').next()?.trim();
let idx: u32 = bg.parse().ok()?;
Some(matches!(idx, 0..=6 | 8))
}
fn query_osc11() -> Option<(u8, u8, u8)> {
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return None;
}
crossterm::terminal::enable_raw_mode().ok()?;
struct RawGuard;
impl Drop for RawGuard {
fn drop(&mut self) {
let _ = crossterm::terminal::disable_raw_mode();
}
}
let _guard = RawGuard;
{
let mut out = std::io::stdout();
out.write_all(b"\x1b]11;?\x07").ok()?;
out.flush().ok()?;
}
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut stdin = std::io::stdin();
let mut buf: Vec<u8> = Vec::new();
let mut byte = [0u8; 1];
while stdin.read(&mut byte).map(|n| n == 1).unwrap_or(false) {
buf.push(byte[0]);
if byte[0] == 0x07
|| (buf.len() >= 2 && buf[buf.len() - 2] == 0x1b && byte[0] == b'\\')
|| buf.len() > 64
{
break;
}
}
let _ = tx.send(buf);
});
let reply = rx.recv_timeout(Duration::from_millis(100)).ok()?;
parse_osc11(&reply)
}
fn parse_osc11(reply: &[u8]) -> Option<(u8, u8, u8)> {
let s = std::str::from_utf8(reply).ok()?;
let after = s.split("rgb:").nth(1)?;
let list = after.split(['\x07', '\x1b']).next()?.trim();
let mut parts = list.split('/');
let r = scale_component(parts.next()?)?;
let g = scale_component(parts.next()?)?;
let b = scale_component(parts.next()?)?;
Some((r, g, b))
}
fn scale_component(g: &str) -> Option<u8> {
let g = g.trim();
if g.is_empty() || g.len() > 4 || !g.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let v = u32::from_str_radix(g, 16).ok()?;
let max = (1u32 << (4 * g.len())) - 1;
Some((v * 255 / max) as u8)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn icon_mode_parses_case_insensitively() {
assert_eq!(IconMode::from_str("unicode"), Ok(IconMode::Unicode));
assert_eq!(IconMode::from_str("Nerd"), Ok(IconMode::Nerd));
assert_eq!(IconMode::from_str(" NONE "), Ok(IconMode::None));
assert_eq!(IconMode::from_str("sparkles"), Err(()));
assert_eq!(IconMode::default(), IconMode::Unicode);
}
#[test]
fn parse_hex_accepts_both_spellings_and_rejects_junk() {
assert_eq!(parse_hex("#7dd3fc"), Some(Color::Rgb(0x7d, 0xd3, 0xfc)));
assert_eq!(parse_hex("7dd3fc"), Some(Color::Rgb(0x7d, 0xd3, 0xfc)));
assert_eq!(parse_hex(" #FFFFFF "), Some(Color::Rgb(255, 255, 255)));
assert_eq!(parse_hex("#fff"), None); assert_eq!(parse_hex("#gggggg"), None); assert_eq!(parse_hex("nope"), None);
}
#[test]
fn is_dark_black_white_and_boundary() {
assert!(is_dark(0, 0, 0)); assert!(!is_dark(255, 255, 255)); assert!(is_dark(127, 127, 127));
assert!(!is_dark(128, 128, 128));
}
#[test]
fn scale_component_widths() {
assert_eq!(scale_component("ffff"), Some(255));
assert_eq!(scale_component("0000"), Some(0));
assert_eq!(scale_component("ff"), Some(255));
assert_eq!(scale_component("00"), Some(0));
assert_eq!(scale_component(""), None);
assert_eq!(scale_component("xyz"), None);
}
#[test]
fn parse_osc11_extracts_rgb() {
let reply = b"\x1b]11;rgb:1a1a/1b1b/2626\x07";
assert_eq!(parse_osc11(reply), Some((0x1a, 0x1b, 0x26)));
}
#[test]
fn colorfgbg_last_field_is_background() {
let saved = std::env::var("COLORFGBG").ok();
std::env::set_var("COLORFGBG", "15;0");
assert_eq!(from_colorfgbg(), Some(true));
std::env::set_var("COLORFGBG", "0;15");
assert_eq!(from_colorfgbg(), Some(false));
std::env::set_var("COLORFGBG", "15;default;0");
assert_eq!(from_colorfgbg(), Some(true));
std::env::set_var("COLORFGBG", "garbage");
assert_eq!(from_colorfgbg(), None);
match saved {
Some(v) => std::env::set_var("COLORFGBG", v),
None => std::env::remove_var("COLORFGBG"),
}
}
#[test]
fn theme_precedence_cli_over_env_over_file_over_default() {
assert_eq!(
resolve_theme_name(
Some("tokyo-night".into()),
Some("gruvbox-dark".into()),
Some("sucher-light".into())
),
"tokyo-night"
);
assert_eq!(
resolve_theme_name(
None,
Some("gruvbox-dark".into()),
Some("sucher-light".into())
),
"gruvbox-dark"
);
assert_eq!(
resolve_theme_name(None, None, Some("sucher-light".into())),
"sucher-light"
);
assert_eq!(resolve_theme_name(None, None, None), "sucher-dark");
}
#[test]
fn icons_precedence_and_default() {
assert_eq!(
resolve_icons(
Some("nerd".into()),
Some("none".into()),
Some("unicode".into())
),
IconMode::Nerd
);
assert_eq!(
resolve_icons(None, Some("none".into()), Some("unicode".into())),
IconMode::None
);
assert_eq!(
resolve_icons(None, Some("bogus".into()), Some("nerd".into())),
IconMode::Unicode
);
assert_eq!(resolve_icons(None, None, None), IconMode::Unicode);
}
#[test]
fn layout_parses_case_insensitively() {
assert_eq!(Layout::from_str("auto"), Ok(Layout::Auto));
assert_eq!(Layout::from_str("Miller"), Ok(Layout::Miller));
assert_eq!(Layout::from_str(" DOUBLE "), Ok(Layout::Double));
assert_eq!(Layout::from_str("triple"), Err(()));
assert_eq!(Layout::default(), Layout::Auto);
}
#[test]
fn layout_cycle_wraps_auto_miller_double() {
assert_eq!(Layout::Auto.cycle(), Layout::Miller);
assert_eq!(Layout::Miller.cycle(), Layout::Double);
assert_eq!(Layout::Double.cycle(), Layout::Auto);
}
#[test]
fn layout_precedence_and_default() {
assert_eq!(
resolve_layout(
Some("miller".into()),
Some("double".into()),
Some("auto".into())
),
Layout::Miller
);
assert_eq!(
resolve_layout(None, Some("double".into()), Some("auto".into())),
Layout::Double
);
assert_eq!(
resolve_layout(None, None, Some("miller".into())),
Layout::Miller
);
assert_eq!(
resolve_layout(None, Some("bogus".into()), Some("miller".into())),
Layout::Auto
);
assert_eq!(resolve_layout(None, None, None), Layout::Auto);
}
#[test]
fn parse_bool_accepts_the_spellings() {
for s in ["1", "true", "YES", " on ", "True"] {
assert_eq!(parse_bool(s), Some(true), "{s}");
}
for s in ["0", "false", "NO", " off ", "False"] {
assert_eq!(parse_bool(s), Some(false), "{s}");
}
assert_eq!(parse_bool("maybe"), None);
assert_eq!(parse_bool(""), None);
}
#[test]
fn git_precedence_and_default() {
assert_eq!(
resolve_git(Some(false), Some("true".into()), Some(true)),
false
);
assert_eq!(resolve_git(None, Some("no".into()), Some(true)), false);
assert_eq!(resolve_git(None, Some("bogus".into()), Some(false)), false);
assert_eq!(resolve_git(None, None, Some(false)), false);
assert_eq!(resolve_git(None, None, None), true);
}
#[test]
fn mouse_precedence_and_default() {
assert_eq!(
resolve_mouse(Some(false), Some("true".into()), Some(true)),
false
);
assert_eq!(resolve_mouse(None, Some("off".into()), Some(true)), false);
assert_eq!(
resolve_mouse(None, Some("bogus".into()), Some(false)),
false
);
assert_eq!(resolve_mouse(None, None, Some(false)), false);
assert_eq!(resolve_mouse(None, None, None), true);
}
#[test]
fn animate_precedence_and_default() {
assert_eq!(
resolve_animate(Some(false), Some("true".into()), Some(true)),
false
);
assert_eq!(resolve_animate(None, Some("off".into()), Some(true)), false);
assert_eq!(
resolve_animate(None, Some("bogus".into()), Some(false)),
false
);
assert_eq!(resolve_animate(None, None, Some(false)), false);
assert_eq!(resolve_animate(None, None, None), true);
}
#[test]
fn overrides_apply_over_base_and_ignore_junk() {
let mut p = Palette::sucher_dark();
let mut map = std::collections::HashMap::new();
map.insert("accent".to_string(), "#123456".to_string());
map.insert("dir".to_string(), "not-a-color".to_string()); map.insert("bogus".to_string(), "#ffffff".to_string()); apply_color_overrides(&mut p, Some(&map));
assert_eq!(p.accent, Color::Rgb(0x12, 0x34, 0x56));
assert_eq!(p.dir, Palette::sucher_dark().dir); }
#[test]
fn malformed_toml_yields_no_file_config() {
let broken: Result<FileConfig, _> = toml::from_str("theme = = =");
assert!(broken.is_err());
}
}