use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
fn default_branches_val() -> Vec<String> {
vec!["main".to_string()]
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VisibleColumns(pub std::collections::HashMap<String, bool>);
impl VisibleColumns {
pub fn is_visible(&self, id: &str) -> bool {
self.0.get(id).copied().unwrap_or(false)
}
pub fn set_visible(&mut self, id: &str, visible: bool) {
self.0.insert(id.to_string(), visible);
}
pub fn toggle(&mut self, id: &str) {
let current = self.is_visible(id);
self.set_visible(id, !current);
}
pub fn apply_defaults(&mut self, cols: &[&'static gitlab_tracker_core::ColumnDef]) {
for col in cols {
self.0
.entry(col.id.to_string())
.or_insert(col.default_visible);
}
}
}
fn default_activity_stale_days() -> u64 {
7
}
fn default_activity_recent_days() -> u64 {
2
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelColorConfig {
pub bg: String,
pub fg: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub refresh_interval_secs: Option<u64>,
#[serde(default = "default_branches_val")]
pub default_branches: Vec<String>,
#[serde(default)]
pub table_label_prefixes: Vec<String>,
#[serde(default)]
pub label_colors: HashMap<String, LabelColorConfig>,
#[serde(default = "default_activity_stale_days")]
pub activity_stale_days: u64,
#[serde(default = "default_activity_recent_days")]
pub activity_recent_days: u64,
#[serde(default)]
pub visible_columns: VisibleColumns,
#[serde(default, alias = "diff_difficulty_profile")]
pub complexity_profile: crate::models::DifficultyProfile,
#[serde(skip)]
pub gitlab_label_colors: HashMap<String, String>,
}
impl Default for AppConfig {
fn default() -> Self {
let mut label_colors = HashMap::new();
label_colors.insert(
"deploy::*".to_string(),
LabelColorConfig {
bg: "green".into(),
fg: "black".into(),
},
);
label_colors.insert(
"review::approved".to_string(),
LabelColorConfig {
bg: "magenta".into(),
fg: "white".into(),
},
);
label_colors.insert(
"review::*".to_string(),
LabelColorConfig {
bg: "cyan".into(),
fg: "black".into(),
},
);
label_colors.insert(
"size::*".to_string(),
LabelColorConfig {
bg: "dark_gray".into(),
fg: "white".into(),
},
);
label_colors.insert(
"bug".to_string(),
LabelColorConfig {
bg: "red".into(),
fg: "white".into(),
},
);
label_colors.insert(
"fix".to_string(),
LabelColorConfig {
bg: "red".into(),
fg: "white".into(),
},
);
Self {
refresh_interval_secs: Some(900),
default_branches: default_branches_val(),
table_label_prefixes: vec!["deploy::".to_string(), "review::".to_string()],
label_colors,
activity_stale_days: default_activity_stale_days(),
activity_recent_days: default_activity_recent_days(),
visible_columns: VisibleColumns::default(),
complexity_profile: crate::models::DifficultyProfile::default(),
gitlab_label_colors: HashMap::new(),
}
}
}
impl AppConfig {
pub fn is_table_label(&self, label: &str) -> bool {
if self.table_label_prefixes.is_empty() {
return true;
}
let label_lower = label.to_lowercase();
self.table_label_prefixes
.iter()
.any(|prefix| label_lower.starts_with(&prefix.to_lowercase()))
}
pub fn activity_badge(&self, updated_at: Option<&str>) -> (&'static str, Color) {
use chrono::{DateTime, Utc};
let Some(ts) = updated_at else {
return ("⬛ Unknown", Color::DarkGray);
};
let Ok(parsed) = DateTime::parse_from_rfc3339(ts) else {
return ("⬛ Unknown", Color::DarkGray);
};
let elapsed_days = (Utc::now() - parsed.to_utc()).num_days();
if elapsed_days < self.activity_recent_days as i64 {
("🟢 Active", Color::Green)
} else if elapsed_days < self.activity_stale_days as i64 {
("🟡 Slowing", Color::Yellow)
} else {
("🔴 Stale", Color::Red)
}
}
pub fn get_label_style(&self, label: &str, gitlab_color: Option<&str>) -> (Color, Color) {
let label_lower = label.to_lowercase();
if let Some(cfg) = self.label_colors.get(&label_lower) {
return (parse_color(&cfg.bg), parse_color(&cfg.fg));
}
for (key, cfg) in &self.label_colors {
if let Some(prefix) = key.strip_suffix('*') {
if label_lower.starts_with(&prefix.to_lowercase()) {
return (parse_color(&cfg.bg), parse_color(&cfg.fg));
}
}
}
if let Some(hex) = gitlab_color {
let bg = parse_color(hex);
let fg = auto_foreground(&bg);
return (bg, fg);
}
(Color::DarkGray, Color::White)
}
}
fn auto_foreground(bg: &Color) -> Color {
let (r, g, b) = match bg {
Color::Rgb(r, g, b) => (*r, *g, *b),
Color::Black => (0, 0, 0),
Color::White => (255, 255, 255),
Color::Red => (128, 0, 0),
Color::Green => (0, 128, 0),
Color::Blue => (0, 0, 128),
Color::Yellow => (128, 128, 0),
Color::Cyan => (0, 128, 128),
Color::Magenta => (128, 0, 128),
_ => return Color::White,
};
let linearise = |c: u8| {
let f = c as f32 / 255.0;
if f <= 0.04045 {
f / 12.92
} else {
((f + 0.055) / 1.055).powf(2.4)
}
};
let luminance = 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
if luminance > 0.179 {
Color::Black
} else {
Color::White
}
}
pub fn parse_color(s: &str) -> Color {
let s_lower = s.to_lowercase();
match s_lower.as_str() {
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
"black" => Color::Black,
"dark_gray" | "dark_grey" => Color::Rgb(88, 88, 100),
"gray" | "grey" | "light_gray" | "light_grey" => Color::Rgb(160, 160, 175),
_ => {
if s.starts_with('#') && s.len() == 7 {
let r = u8::from_str_radix(&s[1..3], 16).unwrap_or(128);
let g = u8::from_str_radix(&s[3..5], 16).unwrap_or(128);
let b = u8::from_str_radix(&s[5..7], 16).unwrap_or(128);
Color::Rgb(r, g, b)
} else {
Color::Rgb(88, 88, 100)
}
}
}
}