1use 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#[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#[derive(Default)]
31pub struct ConfigPanel {
32 info: ConfigInfo,
33 launchers: Vec<(Rect, KeyCode)>,
35}
36
37impl ConfigPanel {
38 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 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 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 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}