Skip to main content

kimun_notes/components/
config_panel.rs

1//! The CFG **drawer view** as a component — the same shape as its sibling
2//! views (Tags/Links/Outline), so `DrawerHost`'s dispatch stays uniform: one
3//! arm per view, never inline view logic in the host.
4
5use ratatui::Frame;
6use ratatui::crossterm::event::KeyCode;
7use ratatui::layout::Rect;
8use ratatui::style::Style;
9use ratatui::widgets::Paragraph;
10
11use crate::components::event_state::EventState;
12use crate::components::events::{AppEvent, AppTx, InputEvent, ScreenEvent};
13use crate::components::panel::panel_block;
14use crate::keys::leader::LeaderAction;
15use crate::settings::themes::Theme;
16
17/// What the CFG drawer view displays — resolved by the host screen when the
18/// view opens (the panel itself holds no settings handle).
19#[derive(Default, Clone)]
20pub struct ConfigInfo {
21    pub theme_name: String,
22    pub leader_key: String,
23    pub preferences_key: String,
24    pub leader_timeout_ms: u64,
25    pub config_path: String,
26}
27
28/// Read-only settings summary plus two launcher keys: `t`/Enter opens the
29/// theme picker, `p` opens Preferences. Clicking a launcher row is its key.
30#[derive(Default)]
31pub struct ConfigPanel {
32    info: ConfigInfo,
33    /// Each launcher row with its key, from the last render.
34    launchers: Vec<(Rect, KeyCode)>,
35}
36
37impl ConfigPanel {
38    /// Refresh what the view shows (called by the host when the view opens).
39    pub fn set_info(&mut self, info: ConfigInfo) {
40        self.info = info;
41    }
42
43    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
44        vec![
45            ("t/⏎".into(), "Theme picker".into()),
46            ("p".into(), "Preferences".into()),
47        ]
48    }
49
50    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
51        if let InputEvent::Mouse(m) = event {
52            if let Some(key) = crate::components::clickable::key_at(&self.launchers, m) {
53                return self.handle_input(&InputEvent::Key(key), tx);
54            }
55            return EventState::NotConsumed;
56        }
57        if let InputEvent::Key(key) = event {
58            match key.code {
59                KeyCode::Char('t') | KeyCode::Enter => {
60                    tx.send(AppEvent::ExecuteLeaderAction(LeaderAction::VaultTheme))
61                        .ok();
62                    return EventState::Consumed;
63                }
64                KeyCode::Char('p') => {
65                    tx.send(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
66                        .ok();
67                    return EventState::Consumed;
68                }
69                _ => {}
70            }
71        }
72        EventState::NotConsumed
73    }
74
75    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
76        let block = panel_block("Config", theme, focused);
77        let inner = block.inner(rect);
78        f.render_widget(block, rect);
79        let info = &self.info;
80        let label = Style::default().fg(theme.gray.to_ratatui());
81        let value = Style::default().fg(theme.fg.to_ratatui());
82        let keycap = theme.action();
83        let mut lines = vec![
84            ratatui::text::Line::from(vec![
85                ratatui::text::Span::styled(" theme    ", label),
86                ratatui::text::Span::styled(info.theme_name.clone(), value),
87            ]),
88            ratatui::text::Line::from(vec![
89                ratatui::text::Span::styled(" leader   ", label),
90                ratatui::text::Span::styled(info.leader_key.clone(), value),
91            ]),
92            ratatui::text::Line::from(vec![
93                ratatui::text::Span::styled(" prefs    ", label),
94                ratatui::text::Span::styled(info.preferences_key.clone(), value),
95            ]),
96            ratatui::text::Line::from(vec![
97                ratatui::text::Span::styled(" timeout  ", label),
98                ratatui::text::Span::styled(
99                    format!("{} ms (which-key reveal)", info.leader_timeout_ms),
100                    value,
101                ),
102            ]),
103            ratatui::text::Line::from(vec![
104                ratatui::text::Span::styled(" config   ", label),
105                ratatui::text::Span::styled(info.config_path.clone(), value),
106            ]),
107            ratatui::text::Line::default(),
108        ];
109        // Each launcher records its click target as it is laid out, so the
110        // rect and the key cannot drift apart. A row clipped off the bottom
111        // is not clickable.
112        self.launchers.clear();
113        for (key, text) in [('t', "theme picker"), ('p', "preferences")] {
114            let y = inner.y + lines.len() as u16;
115            if y < inner.bottom() {
116                // The drawn text is the target — what lights up is what
117                // clicks.
118                let w = (3 + text.len() as u16).min(inner.width);
119                let r = Rect::new(inner.x, y, w, 1);
120                crate::components::clickable::register(r);
121                self.launchers.push((r, KeyCode::Char(key)));
122            }
123            lines.push(ratatui::text::Line::from(vec![
124                ratatui::text::Span::styled(format!(" {key} "), keycap),
125                ratatui::text::Span::styled(text, label),
126            ]));
127        }
128        f.render_widget(Paragraph::new(lines), inner);
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
136    use tokio::sync::mpsc::unbounded_channel;
137
138    fn key(code: KeyCode) -> InputEvent {
139        InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
140    }
141
142    #[test]
143    fn t_and_enter_open_the_theme_picker() {
144        let (tx, mut rx) = unbounded_channel();
145        let mut panel = ConfigPanel::default();
146        for code in [KeyCode::Char('t'), KeyCode::Enter] {
147            assert_eq!(panel.handle_input(&key(code), &tx), EventState::Consumed);
148            assert!(matches!(
149                rx.try_recv(),
150                Ok(AppEvent::ExecuteLeaderAction(LeaderAction::VaultTheme))
151            ));
152        }
153    }
154
155    #[test]
156    fn clicking_a_launcher_row_runs_its_key() {
157        use ratatui::{Terminal, backend::TestBackend};
158        let (tx, mut rx) = unbounded_channel();
159        let mut panel = ConfigPanel::default();
160        let theme = Theme::gruvbox_dark();
161        let mut t = Terminal::new(TestBackend::new(40, 12)).unwrap();
162        t.draw(|f| panel.render(f, f.area(), &theme, true)).unwrap();
163        // Border row + 7 lines: theme picker on row 7, preferences on row 8.
164        panel.handle_input(&crate::test_support::mouse_down_at(5, 7), &tx);
165        assert!(matches!(
166            rx.try_recv(),
167            Ok(AppEvent::ExecuteLeaderAction(LeaderAction::VaultTheme))
168        ));
169        panel.handle_input(&crate::test_support::mouse_down_at(5, 8), &tx);
170        assert!(matches!(
171            rx.try_recv(),
172            Ok(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
173        ));
174        panel.handle_input(&crate::test_support::mouse_down_at(5, 2), &tx);
175        assert!(rx.try_recv().is_err(), "info rows are inert");
176    }
177
178    #[test]
179    fn p_opens_preferences() {
180        let (tx, mut rx) = unbounded_channel();
181        let mut panel = ConfigPanel::default();
182        assert_eq!(
183            panel.handle_input(&key(KeyCode::Char('p')), &tx),
184            EventState::Consumed
185        );
186        assert!(matches!(
187            rx.try_recv(),
188            Ok(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
189        ));
190    }
191
192    #[test]
193    fn other_input_is_not_consumed() {
194        let (tx, mut rx) = unbounded_channel();
195        let mut panel = ConfigPanel::default();
196        assert_eq!(
197            panel.handle_input(&key(KeyCode::Char('x')), &tx),
198            EventState::NotConsumed
199        );
200        assert_eq!(
201            panel.handle_input(&InputEvent::Paste("hi".into()), &tx),
202            EventState::NotConsumed
203        );
204        assert!(rx.try_recv().is_err(), "no event may be emitted");
205    }
206}