rust_bf/
config.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4use std::sync::OnceLock;
5use cross_xdg::BaseDirs;
6use ratatui::style::Color;
7
8#[derive(Debug, Clone)]
9pub struct Colors {
10    pub editor_title_focused: Color,
11    pub editor_title_unfocused: Color,
12    pub gutter_text: Color,
13
14    pub output_title_focused: Color,
15    pub output_title_unfocused: Color,
16
17    pub tape_border_focused: Color,
18    pub tape_border_unfocused: Color,
19    pub tape_cell_empty: Color,
20    pub tape_cell_nonzero: Color,
21    pub tape_cell_pointer: Color,
22
23    pub status_text: Color,
24    pub dialog_title: Color,
25    pub dialog_bg: Color,
26    pub dialog_error: Color,
27    pub dialog_text: Color,
28    pub help_hint: Color,
29
30    pub editor_op_right: Color,     // '>'
31    pub editor_op_left: Color,      // '<'
32    pub editor_op_inc: Color,       // '+'
33    pub editor_op_dec: Color,       // '-'
34    pub editor_op_output: Color,    // '.'
35    pub editor_op_input: Color,     // ','
36    pub editor_op_bracket: Color,   // '[' and ']'
37    pub editor_non_bf: Color,
38}
39
40impl Default for Colors {
41    fn default() -> Self {
42        // Reasonable defaults matching current hard-coded scheme
43        Self {
44            editor_title_focused: Color::Cyan,
45            editor_title_unfocused: Color::Gray,
46            gutter_text: Color::DarkGray,
47
48            output_title_focused: Color::Cyan,
49            output_title_unfocused: Color::Gray,
50
51            tape_border_focused: Color::Cyan,
52            tape_border_unfocused: Color::Gray,
53            tape_cell_empty: Color::DarkGray,
54            tape_cell_nonzero: Color::White,
55            tape_cell_pointer: Color::Yellow,
56
57            status_text: Color::White,
58            dialog_title: Color::White,
59            dialog_bg: Color::Black,
60            dialog_error: Color::Red,
61            dialog_text: Color::White,
62            help_hint: Color::Gray,
63
64            editor_op_right: Color::Cyan,
65            editor_op_left: Color::Green,
66            editor_op_inc: Color::LightGreen,
67            editor_op_dec: Color::Red,
68            editor_op_output: Color::Yellow,
69            editor_op_input: Color::Magenta,
70            editor_op_bracket: Color::LightMagenta,
71            editor_non_bf: Color::Gray,
72        }
73    }
74}
75
76static COLORS: OnceLock<Colors> = OnceLock::new();
77
78pub fn colors() -> &'static Colors {
79    COLORS.get_or_init(|| load_from_toml().unwrap_or_default())
80}
81
82fn parse_color(value: &str) -> Option<Color> {
83    let s = value.trim();
84    if let Some(hex) = s.strip_prefix('#') {
85        if hex.len() == 6 {
86            if let (Ok(r), Ok(g), Ok(b)) = (
87                u8::from_str_radix(&hex[0..2], 16),
88                u8::from_str_radix(&hex[2..4], 16),
89                u8::from_str_radix(&hex[4..6], 16),
90            ) {
91                return Some(Color::Rgb(r, g, b));
92            }
93        }
94    } else {
95        // Try named colors matching ratatui::style::Color variants
96        let name = s.to_ascii_lowercase();
97        return Some(match name.as_str() {
98            "black" => Color::Black,
99            "red" => Color::Red,
100            "green" => Color::Green,
101            "yellow" => Color::Yellow,
102            "blue" => Color::Blue,
103            "magenta" => Color::Magenta,
104            "cyan" => Color::Cyan,
105            "gray" | "grey" => Color::Gray,
106            "darkgray" | "dark_grey" | "darkgrey" | "dark_gray" => Color::DarkGray,
107            "lightred" | "light_red" => Color::LightRed,
108            "lightgreen" | "light_green" => Color::LightGreen,
109            "lightblue" | "light_blue" => Color::LightBlue,
110            "lightmagenta" | "light_magenta" => Color::LightMagenta,
111            "lightcyan" | "light_cyan" => Color::LightCyan,
112            "white" => Color::White,
113            _ => return None,
114        });
115    }
116    None
117}
118
119fn load_from_toml() -> Option<Colors> {
120    // Look for ./config.toml in CWD
121    let base_dirs = BaseDirs::new().unwrap();
122
123    // On Linux: resolves to /home/<user>/.config
124    // On Windows: resolves to C:\Users\<user>\.config
125    // On macOS: resolves to /Users/<user>/.config
126    let config_home = base_dirs.config_home();
127
128    let mut path = PathBuf::from(config_home);
129    path.push("bf.toml");
130
131    let content = fs::read_to_string(path).ok()?;
132    // Very small hand-rolled parser: look for [colors] section and key = value pairs
133    // Values are strings like "#RRGGBB" or named colors.
134    let mut in_colors = false;
135    let mut map: HashMap<String, String> = HashMap::new();
136    for line in content.lines() {
137        let line = line.trim();
138        if line.is_empty() || line.starts_with('#') { continue; }
139        if line.starts_with('[') && line.ends_with(']') {
140            in_colors = &line[1..line.len()-1] == "colors";
141            continue;
142        }
143        if !in_colors { continue; }
144        if let Some(eq) = line.find('=') {
145            let key = line[..eq].trim().to_string();
146            let val_raw = line[eq+1..].trim();
147            // Accept quoted or unquoted
148            let val = if val_raw.starts_with('"') && val_raw.ends_with('"') && val_raw.len() >= 2 {
149                val_raw[1..val_raw.len()-1].to_string()
150            } else { val_raw.to_string() };
151            map.insert(key, val);
152        }
153    }
154
155    let mut cfg = Colors::default();
156
157    macro_rules! set {
158        ($field:ident, $key:literal) => {
159            if let Some(v) = map.get($key).and_then(|s| parse_color(s)) { cfg.$field = v; }
160        };
161    }
162
163    set!(editor_title_focused, "editor_title_focused");
164    set!(editor_title_unfocused, "editor_title_unfocused");
165    set!(gutter_text, "gutter_text");
166
167    set!(output_title_focused, "output_title_focused");
168    set!(output_title_unfocused, "output_title_unfocused");
169
170    set!(tape_border_focused, "tape_border_focused");
171    set!(tape_border_unfocused, "tape_border_unfocused");
172    set!(tape_cell_empty, "tape_cell_empty");
173    set!(tape_cell_nonzero, "tape_cell_nonzero");
174    set!(tape_cell_pointer, "tape_cell_pointer");
175
176    set!(status_text, "status_text");
177    set!(dialog_title, "dialog_title");
178    set!(dialog_bg, "dialog_bg");
179    set!(dialog_error, "dialog_error");
180    set!(dialog_text, "dialog_text");
181    set!(help_hint, "help_hint");
182
183    set!(editor_op_right, "editor_op_right");
184    set!(editor_op_left, "editor_op_left");
185    set!(editor_op_inc, "editor_op_inc");
186    set!(editor_op_dec, "editor_op_dec");
187    set!(editor_op_output, "editor_op_output");
188    set!(editor_op_input, "editor_op_input");
189    set!(editor_op_bracket, "editor_op_bracket");
190    set!(editor_non_bf, "editor_non_bf");
191
192    Some(cfg)
193}