Skip to main content

cbtop/bricks/panels/
config.rs

1//! Configuration panel brick (Layer 3)
2//!
3//! Displays current configuration, profile selection, and auto-save settings.
4
5use crate::brick::{Brick, BrickAssertion, BrickBudget, BrickVerification};
6use presentar_core::{Canvas, Color, Point, TextStyle};
7use presentar_terminal::Theme;
8use std::any::Any;
9
10/// Configuration profile
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ConfigProfile {
13    /// Profile name
14    pub name: String,
15    /// Short description
16    pub description: String,
17    /// Is this the active profile
18    pub is_active: bool,
19}
20
21impl ConfigProfile {
22    /// Create a new profile
23    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
24        Self {
25            name: name.into(),
26            description: description.into(),
27            is_active: false,
28        }
29    }
30
31    /// Create a new active profile
32    pub fn active(name: impl Into<String>, description: impl Into<String>) -> Self {
33        Self {
34            name: name.into(),
35            description: description.into(),
36            is_active: true,
37        }
38    }
39}
40
41/// Configuration panel for viewing and managing settings
42pub struct ConfigPanelBrick {
43    /// Configuration file path
44    pub config_path: String,
45    /// Available profiles
46    pub profiles: Vec<ConfigProfile>,
47    /// Selected profile index
48    pub selected_index: usize,
49    /// Auto-save on exit
50    pub auto_save: bool,
51    /// Load last profile on start
52    pub load_last: bool,
53    /// Theme for rendering
54    pub theme: Theme,
55}
56
57impl ConfigPanelBrick {
58    /// Create a new config panel
59    pub fn new() -> Self {
60        Self {
61            config_path: "~/.config/cbtop/config.toml".to_string(),
62            profiles: vec![
63                ConfigProfile::active("inference", "LLM Inference"),
64                ConfigProfile::new("ml_training", "ML Training"),
65                ConfigProfile::new("stress_test", "Stress Testing"),
66                ConfigProfile::new("power_saving", "Power Saving"),
67            ],
68            selected_index: 0,
69            auto_save: true,
70            load_last: true,
71            theme: Theme::tokyo_night(),
72        }
73    }
74
75    /// Get the active profile
76    pub fn active_profile(&self) -> Option<&ConfigProfile> {
77        self.profiles.iter().find(|p| p.is_active)
78    }
79
80    /// Select next profile
81    pub fn next_profile(&mut self) {
82        if !self.profiles.is_empty() {
83            self.selected_index = (self.selected_index + 1) % self.profiles.len();
84        }
85    }
86
87    /// Select previous profile
88    pub fn prev_profile(&mut self) {
89        if !self.profiles.is_empty() {
90            self.selected_index =
91                (self.selected_index + self.profiles.len() - 1) % self.profiles.len();
92        }
93    }
94
95    /// Activate selected profile
96    pub fn activate_selected(&mut self) {
97        for (i, profile) in self.profiles.iter_mut().enumerate() {
98            profile.is_active = i == self.selected_index;
99        }
100    }
101
102    /// Toggle auto-save setting
103    pub fn toggle_auto_save(&mut self) {
104        self.auto_save = !self.auto_save;
105    }
106
107    /// Toggle load-last setting
108    pub fn toggle_load_last(&mut self) {
109        self.load_last = !self.load_last;
110    }
111
112    /// Paint the config panel
113    pub fn paint(&self, canvas: &mut dyn Canvas, _width: f32, _height: f32) {
114        let label_style = TextStyle {
115            color: self.theme.foreground,
116            ..Default::default()
117        };
118        let dim_style = TextStyle {
119            color: self.theme.dim,
120            ..Default::default()
121        };
122        let active_style = TextStyle {
123            color: Color::new(0.3, 1.0, 0.5, 1.0), // Green for active
124            ..Default::default()
125        };
126        let selected_style = TextStyle {
127            color: Color::new(0.3, 0.8, 1.0, 1.0), // Cyan for selected
128            ..Default::default()
129        };
130
131        canvas.draw_text("Configuration", Point::new(2.0, 2.0), &label_style);
132
133        // Config file path
134        canvas.draw_text("Config:", Point::new(2.0, 4.0), &dim_style);
135        canvas.draw_text(&self.config_path, Point::new(10.0, 4.0), &label_style);
136
137        // Active profile
138        if let Some(profile) = self.active_profile() {
139            canvas.draw_text("Profile:", Point::new(2.0, 5.0), &dim_style);
140            canvas.draw_text(
141                &format!("{} ({})", profile.name, profile.description),
142                Point::new(10.0, 5.0),
143                &active_style,
144            );
145        }
146
147        // Settings checkboxes
148        let auto_save_check = if self.auto_save { "[x]" } else { "[ ]" };
149        let load_last_check = if self.load_last { "[x]" } else { "[ ]" };
150
151        canvas.draw_text(
152            &format!("{} Auto-save on exit", auto_save_check),
153            Point::new(2.0, 7.0),
154            &label_style,
155        );
156        canvas.draw_text(
157            &format!("{} Load last profile on start", load_last_check),
158            Point::new(2.0, 8.0),
159            &label_style,
160        );
161
162        // Profile list
163        canvas.draw_text("Profiles:", Point::new(2.0, 10.0), &dim_style);
164
165        for (i, profile) in self.profiles.iter().enumerate() {
166            let y = 11.0 + i as f32;
167            let prefix = if i == self.selected_index {
168                " > "
169            } else {
170                "   "
171            };
172            let suffix = if profile.is_active { " (active)" } else { "" };
173
174            let style = if i == self.selected_index {
175                &selected_style
176            } else if profile.is_active {
177                &active_style
178            } else {
179                &label_style
180            };
181
182            canvas.draw_text(
183                &format!("{}{}{}", prefix, profile.name, suffix),
184                Point::new(2.0, y),
185                style,
186            );
187        }
188
189        // Help text
190        let help_y = 11.0 + self.profiles.len() as f32 + 2.0;
191        canvas.draw_text(
192            "Press 'P' to activate profile",
193            Point::new(2.0, help_y),
194            &dim_style,
195        );
196        canvas.draw_text(
197            "Press 'S' to save current as new profile",
198            Point::new(2.0, help_y + 1.0),
199            &dim_style,
200        );
201    }
202}
203
204impl Default for ConfigPanelBrick {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl Brick for ConfigPanelBrick {
211    fn brick_name(&self) -> &'static str {
212        "config_panel"
213    }
214
215    fn assertions(&self) -> Vec<BrickAssertion> {
216        vec![
217            BrickAssertion::MinWidth(45),
218            BrickAssertion::MinHeight(18),
219            BrickAssertion::max_latency_ms(8),
220        ]
221    }
222
223    fn budget(&self) -> BrickBudget {
224        BrickBudget::FRAME_60FPS
225    }
226
227    fn verify(&self) -> BrickVerification {
228        let mut v = BrickVerification::new();
229        for assertion in self.assertions() {
230            v.check(&assertion);
231        }
232        v
233    }
234
235    fn as_any(&self) -> &dyn Any {
236        self
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use presentar_core::RecordingCanvas;
244
245    #[test]
246    fn test_config_panel_brick_name() {
247        let panel = ConfigPanelBrick::new();
248        assert_eq!(panel.brick_name(), "config_panel");
249    }
250
251    #[test]
252    fn test_config_panel_paint_default() {
253        let panel = ConfigPanelBrick::new();
254        let mut canvas = RecordingCanvas::new();
255
256        panel.paint(&mut canvas, 80.0, 24.0);
257
258        // Should draw header, config path, profile, settings, and help
259        assert!(!canvas.is_empty());
260        assert!(canvas.command_count() >= 10);
261    }
262
263    #[test]
264    fn test_config_panel_paint_profile_list() {
265        let panel = ConfigPanelBrick::new();
266        let mut canvas = RecordingCanvas::new();
267
268        panel.paint(&mut canvas, 80.0, 24.0);
269
270        // Should draw all 4 profiles + other UI elements
271        // At minimum: header, config path, active profile, 2 checkboxes, "Profiles:" label,
272        // 4 profile entries, 2 help lines = 12+ commands
273        assert!(canvas.command_count() >= 10);
274    }
275
276    #[test]
277    fn test_config_panel_paint_different_selections() {
278        let mut panel = ConfigPanelBrick::new();
279
280        // Test with each profile selected
281        for i in 0..panel.profiles.len() {
282            panel.selected_index = i;
283            let mut canvas = RecordingCanvas::new();
284            panel.paint(&mut canvas, 80.0, 24.0);
285            assert!(!canvas.is_empty());
286        }
287    }
288
289    #[test]
290    fn test_config_panel_paint_with_settings_toggled() {
291        let mut panel = ConfigPanelBrick::new();
292
293        // Toggle settings off
294        panel.toggle_auto_save();
295        panel.toggle_load_last();
296
297        let mut canvas = RecordingCanvas::new();
298        panel.paint(&mut canvas, 80.0, 24.0);
299
300        // Should still render correctly with different checkbox states
301        assert!(!canvas.is_empty());
302    }
303
304    #[test]
305    fn test_config_panel_paint_no_active_profile() {
306        let mut panel = ConfigPanelBrick::new();
307
308        // Deactivate all profiles
309        for profile in &mut panel.profiles {
310            profile.is_active = false;
311        }
312
313        let mut canvas = RecordingCanvas::new();
314        panel.paint(&mut canvas, 80.0, 24.0);
315
316        // Should still render without crashing
317        assert!(!canvas.is_empty());
318    }
319
320    #[test]
321    fn test_config_panel_has_assertions() {
322        let panel = ConfigPanelBrick::new();
323        assert!(!panel.assertions().is_empty());
324    }
325
326    #[test]
327    fn test_config_panel_default_profiles() {
328        let panel = ConfigPanelBrick::new();
329        assert_eq!(panel.profiles.len(), 4);
330        assert!(panel.active_profile().is_some());
331        assert_eq!(panel.active_profile().unwrap().name, "inference");
332    }
333
334    #[test]
335    fn test_profile_navigation() {
336        let mut panel = ConfigPanelBrick::new();
337        assert_eq!(panel.selected_index, 0);
338
339        panel.next_profile();
340        assert_eq!(panel.selected_index, 1);
341
342        panel.prev_profile();
343        assert_eq!(panel.selected_index, 0);
344
345        panel.prev_profile();
346        assert_eq!(panel.selected_index, 3); // Wrap around
347    }
348
349    #[test]
350    fn test_activate_profile() {
351        let mut panel = ConfigPanelBrick::new();
352
353        panel.selected_index = 2;
354        panel.activate_selected();
355
356        assert!(!panel.profiles[0].is_active);
357        assert!(!panel.profiles[1].is_active);
358        assert!(panel.profiles[2].is_active);
359        assert!(!panel.profiles[3].is_active);
360    }
361
362    #[test]
363    fn test_toggle_settings() {
364        let mut panel = ConfigPanelBrick::new();
365        assert!(panel.auto_save);
366        assert!(panel.load_last);
367
368        panel.toggle_auto_save();
369        assert!(!panel.auto_save);
370
371        panel.toggle_load_last();
372        assert!(!panel.load_last);
373    }
374
375    #[test]
376    fn test_profile_creation() {
377        let profile = ConfigProfile::new("test", "Test Profile");
378        assert_eq!(profile.name, "test");
379        assert_eq!(profile.description, "Test Profile");
380        assert!(!profile.is_active);
381
382        let active = ConfigProfile::active("active", "Active Profile");
383        assert!(active.is_active);
384    }
385}