Skip to main content

santui_core/app/
mod.rs

1mod app_state;
2mod handle_key;
3mod palette;
4mod palette_controller;
5mod palette_widget;
6mod plugin_manager;
7mod registry;
8mod screens;
9mod starfield;
10mod status_bar;
11mod theme_manager;
12
13use crate::auth::AuthHandle;
14use crate::config::ConfigManager;
15use crate::plugin::{Plugin, PluginContext};
16use crossterm::event::{Event, KeyEventKind};
17use crossterm::execute;
18use crossterm::terminal::{
19    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
20};
21use ratatui::backend::CrosstermBackend;
22use ratatui::layout::{Constraint, Direction, Layout};
23use ratatui::style::Color;
24use ratatui::Frame;
25use ratatui::Terminal;
26use std::sync::Arc;
27use std::time::Duration;
28
29const VERSION: &str = env!("CARGO_PKG_VERSION");
30
31/// Identifier for a built-in palette command.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub(super) enum BuiltinId {
34    SignInGoogle,
35    SignInGitHub,
36    SignOut,
37    SwitchTheme,
38    About,
39}
40
41/// Return the canonical list of built-in command definitions.
42/// Each entry is `(id, category, label)`.
43pub(super) fn all_builtins() -> Vec<(BuiltinId, &'static str, &'static str)> {
44    vec![
45        (BuiltinId::SignInGoogle, "Auth", "Sign in with Google"),
46        (BuiltinId::SignInGitHub, "Auth", "Sign in with GitHub"),
47        (BuiltinId::SignOut, "Auth", "Sign out"),
48        (BuiltinId::SwitchTheme, "System", "Switch theme"),
49        (BuiltinId::About, "System", "About"),
50    ]
51}
52
53/// Index into either built-in, dynamic (registry), or plugin-registered items.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub(super) enum ItemIndex {
56    Builtin(usize),
57    Dynamic(usize),
58    PluginCmd(usize),
59}
60
61const PAD_L: u16 = 2;
62const PAD_T: u16 = 1;
63const PAD_B: u16 = 1;
64const HEADER_H: u16 = 4;
65const PAL_MIN_W: u16 = 30;
66const PAL_IDEAL_W: u16 = 60;
67
68pub(super) fn max_list_h(content_h: u16) -> u16 {
69    (content_h / 2).saturating_sub(6).max(3)
70}
71
72pub(super) fn pal_w(content_w: u16) -> u16 {
73    let max = content_w.saturating_sub(2);
74    if max < PAL_MIN_W {
75        return max;
76    }
77    max.clamp(PAL_MIN_W, PAL_IDEAL_W)
78}
79
80/// Scale the brightness of an RGB foreground color by `factor`,
81/// preserving its hue. Non-RGB colors (Reset, Indexed) pass through.
82/// Parse a hex colour string like `"#ff8800"` or `"ff8800"` into a `Color::Rgb`.
83pub(super) fn parse_hex(s: &str) -> Option<Color> {
84    let s = s.trim_start_matches('#');
85    if s.len() != 6 {
86        return None;
87    }
88    let val = u32::from_str_radix(s, 16).ok()?;
89    Some(Color::Rgb(
90        ((val >> 16) & 0xFF) as u8,
91        ((val >> 8) & 0xFF) as u8,
92        (val & 0xFF) as u8,
93    ))
94}
95
96fn dim_color(fg: Color, factor: f64) -> Color {
97    match fg {
98        Color::Rgb(r, g, b) => Color::Rgb(
99            (r as f64 * factor) as u8,
100            (g as f64 * factor) as u8,
101            (b as f64 * factor) as u8,
102        ),
103        _ => fg,
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn pal_w_given_small_width_returns_width() {
113        let w = pal_w(10);
114        assert_eq!(w, 8);
115    }
116
117    #[test]
118    fn pal_w_returns_content_minus_two_when_above_min() {
119        let w = pal_w(40);
120        assert_eq!(w, 38);
121    }
122
123    #[test]
124    fn pal_w_clamps_to_max() {
125        let w = pal_w(200);
126        assert_eq!(w, 60);
127    }
128
129    #[test]
130    fn pal_w_zero_saturates() {
131        let w = pal_w(0);
132        assert_eq!(w, 0);
133    }
134
135    #[test]
136    fn pal_w_one_saturates() {
137        let w = pal_w(1);
138        assert_eq!(w, 0);
139    }
140
141    #[test]
142    fn max_list_h_small_height() {
143        let h = max_list_h(10);
144        assert_eq!(h, 3);
145    }
146
147    #[test]
148    fn max_list_h_normal() {
149        let h = max_list_h(48);
150        assert_eq!(h, 18);
151    }
152
153    #[test]
154    fn max_list_h_large() {
155        let h = max_list_h(100);
156        assert_eq!(h, 44);
157    }
158
159    #[test]
160    fn max_list_h_minimum() {
161        let h = max_list_h(4);
162        assert_eq!(h, 3);
163    }
164
165    #[test]
166    fn filtered_themes_empty_query_returns_all() {
167        let app = Santui::new();
168        let themes = app.theme_manager.filtered();
169        assert_eq!(themes.len(), app.theme_manager.themes.len());
170    }
171
172    #[test]
173    fn filtered_themes_matches_partial() {
174        let mut app = Santui::new();
175        app.theme_manager.picker_query = "cat".into();
176        let themes = app.theme_manager.filtered();
177        assert!(themes.len() >= 3);
178        for &i in &themes {
179            let name = app.theme_manager.themes[i].0.to_lowercase();
180            assert!(
181                name.contains("cat"),
182                "expected '{}' to contain 'cat'",
183                app.theme_manager.themes[i].0
184            );
185        }
186    }
187
188    #[test]
189    fn filtered_themes_no_match() {
190        let mut app = Santui::new();
191        app.theme_manager.picker_query = "xyznonexistent".into();
192        let themes = app.theme_manager.filtered();
193        assert!(themes.is_empty());
194    }
195
196    #[test]
197    fn filtered_themes_case_insensitive() {
198        let mut app = Santui::new();
199        app.theme_manager.picker_query = "NORD".into();
200        let themes = app.theme_manager.filtered();
201        assert_eq!(themes.len(), 1);
202        assert_eq!(app.theme_manager.themes[themes[0]].0, "Nord");
203    }
204
205    // ---- dim_color tests ----
206
207    #[test]
208    fn dim_color_black_stays_black() {
209        assert_eq!(dim_color(Color::Rgb(0, 0, 0), 0.45), Color::Rgb(0, 0, 0));
210    }
211
212    #[test]
213    fn dim_color_white_becomes_gray() {
214        assert_eq!(
215            dim_color(Color::Rgb(255, 255, 255), 0.45),
216            Color::Rgb(114, 114, 114)
217        );
218    }
219
220    #[test]
221    fn dim_color_gold_becomes_dim_gold() {
222        // Gold RGB(255, 185, 0) × 0.45 = (114, 83, 0)
223        assert_eq!(
224            dim_color(Color::Rgb(255, 185, 0), 0.45),
225            Color::Rgb(114, 83, 0)
226        );
227    }
228
229    #[test]
230    fn dim_color_full_factor_returns_original() {
231        assert_eq!(
232            dim_color(Color::Rgb(123, 200, 50), 1.0),
233            Color::Rgb(123, 200, 50)
234        );
235    }
236
237    #[test]
238    fn dim_color_zero_factor_returns_black() {
239        assert_eq!(
240            dim_color(Color::Rgb(100, 150, 200), 0.0),
241            Color::Rgb(0, 0, 0)
242        );
243    }
244
245    #[test]
246    fn dim_color_preserves_hue() {
247        // Dimming preserves the ratio between channels
248        let dimmed = dim_color(Color::Rgb(200, 100, 50), 0.5);
249        assert_eq!(dimmed, Color::Rgb(100, 50, 25));
250    }
251
252    #[test]
253    fn dim_color_reset_passes_through() {
254        assert_eq!(dim_color(Color::Reset, 0.45), Color::Reset);
255    }
256
257    #[test]
258    fn dim_color_indexed_passes_through() {
259        assert_eq!(dim_color(Color::Indexed(7), 0.45), Color::Indexed(7));
260    }
261
262    // ---- dim_color edge cases ----
263
264    #[test]
265    fn dim_color_factor_above_one_saturates_at_255() {
266        // Rust's f64→u8 cast saturates: 400.0 as u8 = 255, not 144.
267        let result = dim_color(Color::Rgb(200, 100, 50), 2.0);
268        // 200*2=400 → 255, 100*2=200, 50*2=100
269        assert_eq!(result, Color::Rgb(255, 200, 100));
270    }
271
272    #[test]
273    fn dim_color_negative_factor_saturates_to_zero() {
274        // Rust defines f64→u8 cast as saturating at 0.
275        assert_eq!(
276            dim_color(Color::Rgb(100, 150, 200), -0.5),
277            Color::Rgb(0, 0, 0)
278        );
279    }
280
281    #[test]
282    fn dim_color_tiny_factor_is_effectively_zero() {
283        // 1e-12 * 255 < 1, so all channels truncate to 0.
284        assert_eq!(
285            dim_color(Color::Rgb(200, 150, 100), 1e-12),
286            Color::Rgb(0, 0, 0)
287        );
288    }
289
290    #[test]
291    fn dim_color_nan_factor_does_not_panic() {
292        // f64::NAN * integer → NaN, (NaN) as u8 = 0 (saturating cast).
293        let result = dim_color(Color::Rgb(100, 150, 200), f64::NAN);
294        assert_eq!(result, Color::Rgb(0, 0, 0));
295    }
296
297    #[test]
298    fn dim_color_inf_factor_does_not_panic() {
299        // Rust's f64→u8 cast: ∞ saturates to 255, not 0.
300        let result = dim_color(Color::Rgb(100, 150, 200), f64::INFINITY);
301        assert_eq!(result, Color::Rgb(255, 255, 255));
302    }
303
304    #[test]
305    fn dim_color_indexed_196_passes_through() {
306        // Indexed 196 = bright red in 256-color ANSI palette
307        assert_eq!(dim_color(Color::Indexed(196), 0.45), Color::Indexed(196));
308    }
309
310    // ---- parse_hex tests ----
311
312    #[test]
313    fn parse_hex_valid_with_hash() {
314        assert_eq!(parse_hex("#ff8800"), Some(Color::Rgb(255, 136, 0)));
315    }
316
317    #[test]
318    fn parse_hex_valid_without_hash() {
319        assert_eq!(parse_hex("ff8800"), Some(Color::Rgb(255, 136, 0)));
320    }
321
322    #[test]
323    fn parse_hex_all_zeros() {
324        assert_eq!(parse_hex("#000000"), Some(Color::Rgb(0, 0, 0)));
325    }
326
327    #[test]
328    fn parse_hex_all_fs() {
329        assert_eq!(parse_hex("#ffffff"), Some(Color::Rgb(255, 255, 255)));
330    }
331
332    #[test]
333    fn parse_hex_mixed_case() {
334        assert_eq!(parse_hex("#Ff8800"), Some(Color::Rgb(255, 136, 0)));
335    }
336
337    #[test]
338    fn parse_hex_uppercase() {
339        assert_eq!(parse_hex("#FF8800"), Some(Color::Rgb(255, 136, 0)));
340    }
341
342    #[test]
343    fn parse_hex_invalid_chars_returns_none() {
344        assert_eq!(parse_hex("#gggggg"), None);
345    }
346
347    #[test]
348    fn parse_hex_too_short_returns_none() {
349        assert_eq!(parse_hex("#fff"), None);
350    }
351
352    #[test]
353    fn parse_hex_too_long_returns_none() {
354        assert_eq!(parse_hex("#ff8800ff"), None);
355    }
356
357    #[test]
358    fn parse_hex_empty_string_returns_none() {
359        assert_eq!(parse_hex(""), None);
360    }
361
362    #[test]
363    fn parse_hex_just_hash_returns_none() {
364        assert_eq!(parse_hex("#"), None);
365    }
366
367    #[test]
368    fn parse_hex_double_hash_returns_some() {
369        // trim_start_matches('#') strips ALL leading #s, so "##ff8800" → "ff8800" (len 6) → valid!
370        assert_eq!(parse_hex("##ff8800"), Some(Color::Rgb(255, 136, 0)));
371    }
372
373    #[test]
374    fn parse_hex_hash_only_returns_none() {
375        // "##" → strips both #s → "" → len 0 ≠ 6
376        assert_eq!(parse_hex("##"), None);
377    }
378
379    #[test]
380    fn parse_hex_hash_in_middle_returns_none() {
381        // "ff88#00" — no leading # to strip, len 7 ≠ 6
382        assert_eq!(parse_hex("ff88#00"), None);
383    }
384
385    // ---- proptest: dim_color ----
386
387    use proptest::prelude::*;
388
389    proptest! {
390        #[test]
391        fn dim_color_identity(
392            r in any::<u8>(),
393            g in any::<u8>(),
394            b in any::<u8>(),
395        ) {
396            let color = Color::Rgb(r, g, b);
397            prop_assert_eq!(dim_color(color, 1.0), color);
398        }
399
400        #[test]
401        fn dim_color_zero_factor_yields_black(
402            r in any::<u8>(),
403            g in any::<u8>(),
404            b in any::<u8>(),
405        ) {
406            let result = dim_color(Color::Rgb(r, g, b), 0.0);
407            prop_assert_eq!(result, Color::Rgb(0, 0, 0));
408        }
409
410        #[test]
411        fn dim_color_non_rgb_passthrough(
412            f in prop::num::f64::ANY,
413        ) {
414            // Non-RGB colours should never be modified, regardless of factor.
415            prop_assert_eq!(dim_color(Color::Reset, f), Color::Reset);
416            prop_assert_eq!(dim_color(Color::Indexed(123), f), Color::Indexed(123));
417        }
418
419        #[test]
420        fn dim_color_channel_never_exceeds_original(
421            r in any::<u8>(),
422            g in any::<u8>(),
423            b in any::<u8>(),
424            f in 0.0f64..=1.0f64,
425        ) {
426            let result = dim_color(Color::Rgb(r, g, b), f);
427            if let Color::Rgb(r2, g2, b2) = result {
428                prop_assert!(r2 <= r, "red {} > original {}", r2, r);
429                prop_assert!(g2 <= g, "green {} > original {}", g2, g);
430                prop_assert!(b2 <= b, "blue {} > original {}", b2, b);
431            } else {
432                panic!("expected Rgb, got {:?}", result);
433            }
434        }
435
436        #[test]
437        fn dim_color_hue_preserved_within_tolerance(
438            r in any::<u8>(),
439            g in any::<u8>(),
440            b in any::<u8>(),
441            f in 0.0f64..=1.0f64,
442        ) {
443            let result = dim_color(Color::Rgb(r, g, b), f);
444            if let Color::Rgb(r2, g2, b2) = result {
445                // Cross-multiplication to check channel ratios are preserved.
446                // Each channel has f64→u8 truncation error < 1, so:
447                //   |e2*r - e1*g| ≤ max(r, g)  where e1,e2 ∈ [0,1)
448                // This is a tight bound with no false negatives.
449                if r != 0 && g != 0 && r2 != 0 && g2 != 0 {
450                    let diff = (r2 as i32 * g as i32 - g2 as i32 * r as i32)
451                        .unsigned_abs();
452                    prop_assert!(diff <= r.max(g) as u32,
453                        "r:g ratio not preserved: {}:{} vs {}:{} (f={})",
454                        r, g, r2, g2, f);
455                }
456                if r != 0 && b != 0 && r2 != 0 && b2 != 0 {
457                    let diff = (r2 as i32 * b as i32 - b2 as i32 * r as i32)
458                        .unsigned_abs();
459                    prop_assert!(diff <= r.max(b) as u32,
460                        "r:b ratio not preserved: {}:{} vs {}:{} (f={})",
461                        r, b, r2, b2, f);
462                }
463            }
464        }
465    }
466}
467
468pub struct Santui {
469    /// All plugin lifecycle management.
470    pub(super) plugin_manager: plugin_manager::PluginManager,
471    /// In-app event bus for decoupled communication.
472    pub(super) event_bus: crate::event::EventBus,
473    /// Authentication handle (set by main.rs before run()).
474    pub(super) auth: Option<Arc<dyn AuthHandle>>,
475    /// Centralized application state.
476    pub(super) app_state: app_state::AppState,
477    /// Manages theme selection, preview, and theme-picker UI state.
478    pub(super) theme_manager: theme_manager::ThemeManager,
479    /// Command palette overlay state and key handling.
480    palette_controller: palette_controller::PaletteController,
481    /// Hot-reloadable configuration manager.
482    pub(super) config_manager: crate::config::ConfigManager,
483    /// Starfield background animation.
484    pub(super) starfield: starfield::Starfield,
485}
486
487impl Default for Santui {
488    fn default() -> Self {
489        Self::new()
490    }
491}
492
493impl Santui {
494    pub fn new() -> Self {
495        let theme_manager = theme_manager::ThemeManager::new();
496        let theme = theme_manager.current().clone();
497        Santui {
498            plugin_manager: plugin_manager::PluginManager::new(),
499            event_bus: crate::event::EventBus::new(),
500            auth: None,
501            app_state: app_state::AppState::new(theme),
502            theme_manager,
503            palette_controller: palette_controller::PaletteController::new(),
504            config_manager: ConfigManager::new(std::path::PathBuf::new()),
505            starfield: starfield::Starfield::new(),
506        }
507    }
508
509    /// Set the main loop tick rate (default 100ms).
510    /// Lower values = smoother animation but more CPU.
511    pub fn set_tick_rate(&mut self, duration: Duration) {
512        self.config_manager.set_tick_rate(duration);
513    }
514
515    pub fn set_auth(&mut self, auth: Arc<dyn AuthHandle>) {
516        self.auth = Some(auth);
517    }
518
519    /// Set the config directory and load (or create) `config.toml`.
520    /// Call before `run()`.
521    pub fn set_config_dir(&mut self, dir: std::path::PathBuf) {
522        self.config_manager = ConfigManager::new(dir.clone());
523        self.theme_manager.load_user_themes(&dir);
524        self.apply_config();
525    }
526
527    /// Apply the loaded config (theme, custom colors) to the current app state.
528    pub(super) fn apply_config(&mut self) {
529        let cfg = self.config_manager.config().clone();
530
531        // Apply default theme if specified.
532        if let Some(ref theme_name) = cfg.theme {
533            let lower = theme_name.to_lowercase();
534            if let Some(idx) = self
535                .theme_manager
536                .themes
537                .iter()
538                .position(|(n, _)| n.to_lowercase() == lower)
539            {
540                self.select_theme(idx);
541            }
542        }
543
544        // Apply custom color overrides.
545        if let Some(ref custom) = cfg.custom_theme {
546            let mut t = self.app_state.theme.clone();
547            if let Some(ref v) = custom.accent {
548                if let Some(c) = parse_hex(v) {
549                    t.accent = c;
550                }
551            }
552            if let Some(ref v) = custom.highlight {
553                if let Some(c) = parse_hex(v) {
554                    t.highlight = c;
555                }
556            }
557            if let Some(ref v) = custom.logo {
558                if let Some(c) = parse_hex(v) {
559                    t.logo = c;
560                }
561            }
562            if let Some(ref v) = custom.text {
563                if let Some(c) = parse_hex(v) {
564                    t.text = c;
565                }
566            }
567            if let Some(ref v) = custom.text_muted {
568                if let Some(c) = parse_hex(v) {
569                    t.text_muted = c;
570                }
571            }
572            if let Some(ref v) = custom.background {
573                if let Some(c) = parse_hex(v) {
574                    t.background = c;
575                }
576            }
577            if let Some(ref v) = custom.background_panel {
578                if let Some(c) = parse_hex(v) {
579                    t.background_panel = c;
580                }
581            }
582            if let Some(ref v) = custom.background_overlay {
583                if let Some(c) = parse_hex(v) {
584                    t.background_overlay = c;
585                }
586            }
587            if let Some(ref v) = custom.border {
588                if let Some(c) = parse_hex(v) {
589                    t.border = c;
590                }
591            }
592            if let Some(ref v) = custom.success {
593                if let Some(c) = parse_hex(v) {
594                    t.success = c;
595                }
596            }
597            if let Some(ref v) = custom.error {
598                if let Some(c) = parse_hex(v) {
599                    t.error = c;
600                }
601            }
602            if let Some(ref v) = custom.inverted_text {
603                if let Some(c) = parse_hex(v) {
604                    t.inverted_text = c;
605                }
606            }
607            self.event_bus.emit(crate::event::Event::ThemeChanged(t));
608        }
609
610        self.config_manager.ack();
611    }
612
613    /// Get the currently selected theme name.
614    pub fn current_theme_name(&self) -> &str {
615        &self.theme_manager.themes[self.theme_manager.current_idx].0
616    }
617
618    pub fn register(&mut self, plugin: Box<dyn Plugin + Send>) {
619        self.plugin_manager.register(plugin);
620    }
621
622    pub fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
623        enable_raw_mode()?;
624        let mut stdout = std::io::stdout();
625        execute!(stdout, EnterAlternateScreen)?;
626        let backend = CrosstermBackend::new(stdout);
627        let mut terminal = Terminal::new(backend)?;
628        terminal.clear()?;
629
630        // Resize starfield to match actual terminal dimensions.
631        let (term_w, term_h) = crossterm::terminal::size()?;
632        self.starfield.resize(term_w, term_h);
633
634        let mut ctx = PluginContext {
635            theme: self.app_state.theme.clone(),
636            auth: self.auth.clone(),
637            data_dir: self.plugin_manager.data_dir().to_path_buf(),
638        };
639        self.plugin_manager.init_all(&mut ctx)?;
640
641        // Populate palette "Plugins" category from registry.toml.
642        self.plugin_manager.read_registry_installed();
643
644        while self.app_state.running {
645            self.plugin_manager.tick_all();
646
647            // Poll for config changes (hot-reload).
648            self.config_manager.poll();
649            if self.config_manager.dirty {
650                self.apply_config();
651            }
652
653            // Check for plugin binary updates (hot-reload).
654            let mut ctx = PluginContext {
655                theme: self.app_state.theme.clone(),
656                auth: self.auth.clone(),
657                data_dir: self.plugin_manager.data_dir().to_path_buf(),
658            };
659            self.plugin_manager.check_reloads(&mut ctx);
660
661            // Poll registry.toml for changes (registry plugin writes it).
662            self.plugin_manager.poll_registry_installed();
663
664            // Drain the event bus and forward events to subsystems.
665            let events = self.event_bus.drain();
666            self.app_state.process_events(&events);
667            self.plugin_manager.process_events(&events);
668
669            // Check for pending non-blocking sign-in results.
670            if let Some(ref auth) = self.auth {
671                if let Some(result) = auth.drain_pending_sign_in() {
672                    match result {
673                        Ok(user) => {
674                            self.plugin_manager.on_user_update_all(Some(&user));
675                            self.event_bus.emit(crate::event::Event::UserUpdated);
676                        }
677                        Err(e) => {
678                            log::error!("[auth] background sign-in error: {e}");
679                        }
680                    }
681                }
682            }
683
684            self.starfield.tick = self.starfield.tick.wrapping_add(1);
685            self.starfield.update();
686
687            terminal.draw(|f| self.render(f))?;
688
689            if crossterm::event::poll(self.config_manager.tick_rate())? {
690                if let Event::Key(key) = crossterm::event::read()? {
691                    if key.kind != KeyEventKind::Press {
692                        continue;
693                    }
694                    self.handle_key(key);
695                }
696            }
697        }
698
699        disable_raw_mode()?;
700        execute!(std::io::stdout(), LeaveAlternateScreen)?;
701        terminal.show_cursor()?;
702        Ok(())
703    }
704
705    fn render(&self, f: &mut Frame) {
706        let area = f.area();
707
708        let chunks = Layout::default()
709            .direction(Direction::Vertical)
710            .constraints([Constraint::Min(0), Constraint::Length(1)])
711            .split(area);
712
713        match self.plugin_manager.active() {
714            None => {
715                if self.app_state.show_about {
716                    self.render_about(f, chunks[0]);
717                } else {
718                    self.render_splash(f, chunks[0]);
719                }
720            }
721            Some(idx) => {
722                self.plugin_manager.render(idx, f, chunks[0]);
723            }
724        }
725
726        let hints = self
727            .plugin_manager
728            .active()
729            .map(|idx| self.plugin_manager.status_hints(idx))
730            .unwrap_or_default();
731        let current_user = self.auth.as_ref().and_then(|a| a.current_user());
732        let auth_message = self.auth.as_ref().and_then(|a| a.auth_message());
733        status_bar::StatusBar {
734            theme: &self.app_state.theme,
735            palette_open: self.palette_controller.is_open(),
736            theme_picker_open: self.app_state.theme_picker_open,
737            about_open: self.app_state.show_about,
738            plugin_active: self.plugin_manager.active().is_some(),
739            active_plugin_hints: &hints,
740            user: current_user.as_ref(),
741            config_error: self.config_manager.error(),
742            auth_message: auth_message.as_deref(),
743        }
744        .render(f, chunks[1]);
745
746        if self.palette_controller.is_open() || self.app_state.theme_picker_open {
747            let dim_bg = self.app_state.theme.background_overlay;
748            let buf = f.buffer_mut();
749            // Scale both foreground AND background brightness by 45%.
750            // For cells with an explicit background (e.g. the gold
751            // highlight on the selected radio station), dimming both
752            // preserves the contrast ratio — the text stays readable.
753            // Cells without an explicit bg fall back to dim_bg.
754            const DIM: f64 = 0.45;
755            for y in area.top()..area.bottom() {
756                for x in area.left()..area.right() {
757                    if let Some(cell) = buf.cell_mut((x, y)) {
758                        let mut style = cell.style();
759                        if let Some(fg) = style.fg {
760                            style.fg = Some(dim_color(fg, DIM));
761                        }
762                        if let Some(bg) = style.bg {
763                            style.bg = Some(dim_color(bg, DIM));
764                        } else {
765                            style.bg = Some(dim_bg);
766                        }
767                        cell.set_style(style);
768                    }
769                }
770            }
771        }
772
773        if self.palette_controller.is_open() {
774            let cmds = self.plugin_manager.commands();
775            self.palette_controller.render(
776                f,
777                chunks[0],
778                &self.app_state.theme,
779                self.starfield.tick,
780                &self.app_state.builtin_items,
781                self.plugin_manager.dynamic_items(),
782                cmds,
783            );
784        }
785
786        if self.app_state.theme_picker_open {
787            self.theme_manager.render_picker(
788                f,
789                chunks[0],
790                &self.app_state.theme,
791                self.starfield.tick,
792            );
793        }
794    }
795}