1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::style::Style;
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
6use unicode_width::UnicodeWidthStr;
7
8use crate::components::Component;
9use crate::components::event_state::EventState;
10use crate::components::events::{AppTx, InputEvent};
11use crate::settings::themes::Theme;
12
13use super::{ClickMap, text_rect};
14
15const ROW_COUNT: usize = 4;
17const ROW_LEADER_TIMEOUT: usize = 3;
18
19const MAX_LEADER_TIMEOUT_MS: u64 = 2000;
20const LEADER_TIMEOUT_STEP: u64 = 50;
21
22pub struct DisplaySection {
23 pub use_nerd_fonts: bool,
24 pub update_check: bool,
26 pub mouse: bool,
29 pub leader_timeout_ms: u64,
32 list_state: ListState,
33 clicks: ClickMap,
34}
35
36impl DisplaySection {
37 pub fn new(
38 use_nerd_fonts: bool,
39 update_check: bool,
40 mouse: bool,
41 leader_timeout_ms: u64,
42 ) -> Self {
43 let mut list_state = ListState::default();
44 list_state.select(Some(0));
45 Self {
46 use_nerd_fonts,
47 update_check,
48 mouse,
49 leader_timeout_ms,
50 list_state,
51 clicks: ClickMap::default(),
52 }
53 }
54
55 pub fn handle_mouse(
59 &mut self,
60 m: &ratatui::crossterm::event::MouseEvent,
61 ) -> Option<ratatui::crossterm::event::KeyEvent> {
62 use ratatui::crossterm::event::KeyCode;
63 let selected = self.list_state.selected();
64 let activate = (selected != Some(ROW_LEADER_TIMEOUT)).then_some(KeyCode::Enter);
65 self.clicks
66 .resolve(m, selected, activate)
67 .into_key(|row| self.list_state.select(Some(row)))
68 }
69
70 fn toggle_selected(&mut self) {
72 match self.list_state.selected() {
73 Some(0) => self.use_nerd_fonts = !self.use_nerd_fonts,
74 Some(1) => self.update_check = !self.update_check,
75 Some(2) => self.mouse = !self.mouse,
76 _ => {}
77 }
78 }
79
80 fn adjust_leader_timeout(&mut self, increase: bool) {
81 self.leader_timeout_ms = if increase {
82 (self.leader_timeout_ms + LEADER_TIMEOUT_STEP).min(MAX_LEADER_TIMEOUT_MS)
83 } else {
84 self.leader_timeout_ms.saturating_sub(LEADER_TIMEOUT_STEP)
85 };
86 }
87
88 fn move_selection(&mut self, delta: isize) {
89 let current = self.list_state.selected().unwrap_or(0) as isize;
90 let next = (current + delta).rem_euclid(ROW_COUNT as isize);
91 self.list_state.select(Some(next as usize));
92 }
93}
94
95impl Component for DisplaySection {
96 fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
97 let InputEvent::Key(key) = event else {
98 return EventState::NotConsumed;
99 };
100 use ratatui::crossterm::event::KeyCode;
101 match key.code {
102 KeyCode::Enter | KeyCode::Char(' ') => {
103 self.toggle_selected();
104 EventState::Consumed
105 }
106 KeyCode::Up | KeyCode::Char('k') => {
107 self.move_selection(-1);
108 EventState::Consumed
109 }
110 KeyCode::Down | KeyCode::Char('j') => {
111 self.move_selection(1);
112 EventState::Consumed
113 }
114 KeyCode::Left | KeyCode::Char('h')
115 if self.list_state.selected() == Some(ROW_LEADER_TIMEOUT) =>
116 {
117 self.adjust_leader_timeout(false);
118 EventState::Consumed
119 }
120 KeyCode::Right | KeyCode::Char('l')
121 if self.list_state.selected() == Some(ROW_LEADER_TIMEOUT) =>
122 {
123 self.adjust_leader_timeout(true);
124 EventState::Consumed
125 }
126 _ => EventState::NotConsumed,
127 }
128 }
129
130 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
131 let border_style = theme.border_style(focused);
132 let block = Block::default()
133 .title("Display")
134 .borders(Borders::ALL)
135 .border_style(border_style)
136 .style(theme.base_style());
137
138 use ratatui::crossterm::event::KeyCode;
139 let checkbox = |on: bool| if on { "[x]" } else { "[ ]" };
140 let fg = Style::default().fg(theme.fg.to_ratatui());
141 let action = theme.action();
142 let inner = block.inner(rect);
143 let mut controls: Vec<(usize, u16, u16, KeyCode)> = Vec::new();
146 let mut items = Vec::new();
147 for (row, label, on) in [
149 (0, " Use Nerd Fonts ", self.use_nerd_fonts),
150 (1, " Check for updates on startup ", self.update_check),
151 (2, " Capture mouse (restart to apply) ", self.mouse),
152 ] {
153 controls.push((row, label.width() as u16, 3, KeyCode::Enter));
154 items.push(
155 ListItem::new(Line::from(vec![
156 Span::raw(label),
157 Span::styled(checkbox(on), action),
158 ]))
159 .style(fg),
160 );
161 }
162 let label = " Which-key Delay ";
164 let value = format!(" {}ms ", self.leader_timeout_ms);
165 let left_col = label.width() as u16;
166 let right_col = left_col + 1 + value.width() as u16;
167 controls.push((ROW_LEADER_TIMEOUT, left_col, 1, KeyCode::Left));
168 controls.push((ROW_LEADER_TIMEOUT, right_col, 1, KeyCode::Right));
169 items.push(
170 ListItem::new(Line::from(vec![
171 Span::raw(label),
172 Span::styled("◀", action),
173 Span::raw(value),
174 Span::styled("▶", action),
175 Span::raw(" (←/→ to change)"),
176 ]))
177 .style(fg),
178 );
179
180 let list = List::new(items)
181 .block(block)
182 .style(theme.base_style())
183 .highlight_style(
184 Style::default()
185 .fg(theme.selection_fg.to_ratatui())
186 .bg(theme.selection_bg.to_ratatui()),
187 );
188 f.render_stateful_widget(list, rect, &mut self.list_state);
189
190 self.clicks.clear();
192 let offset = self.list_state.offset();
193 let row_rect = |row: usize| {
194 let y = inner.y as usize + row.saturating_sub(offset);
195 if row < offset || y >= inner.bottom() as usize {
196 Rect::default()
197 } else {
198 Rect::new(inner.x, y as u16, inner.width, 1)
199 }
200 };
201 for row in 0..ROW_COUNT {
202 self.clicks.row(row_rect(row), row);
203 }
204 for (row, col, width, key) in controls {
205 self.clicks
206 .control(text_rect(row_rect(row), col, width), row, key);
207 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::components::events::InputEvent;
215 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
216
217 fn key(code: KeyCode) -> InputEvent {
218 InputEvent::Key(KeyEvent {
219 code,
220 modifiers: KeyModifiers::NONE,
221 kind: KeyEventKind::Press,
222 state: KeyEventState::NONE,
223 })
224 }
225
226 #[test]
227 fn enter_toggles_nerd_fonts() {
228 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
229 let mut section = DisplaySection::new(true, true, true, 400);
230 section.handle_input(&key(KeyCode::Enter), &tx);
231 assert!(!section.use_nerd_fonts);
232 section.handle_input(&key(KeyCode::Enter), &tx);
233 assert!(section.use_nerd_fonts);
234 }
235
236 #[test]
237 fn space_toggles_nerd_fonts() {
238 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
239 let mut section = DisplaySection::new(false, true, true, 400);
240 section.handle_input(&key(KeyCode::Char(' ')), &tx);
241 assert!(section.use_nerd_fonts);
242 }
243
244 #[test]
245 fn down_then_toggle_flips_update_check_only() {
246 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
247 let mut section = DisplaySection::new(true, true, true, 400);
248 section.handle_input(&key(KeyCode::Down), &tx);
249 section.handle_input(&key(KeyCode::Enter), &tx);
250 assert!(!section.update_check, "update_check should toggle off");
251 assert!(section.use_nerd_fonts, "nerd fonts should be untouched");
252 }
253
254 #[test]
255 fn down_twice_then_toggle_flips_mouse_only() {
256 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
257 let mut section = DisplaySection::new(true, true, true, 400);
258 section.handle_input(&key(KeyCode::Down), &tx);
259 section.handle_input(&key(KeyCode::Down), &tx);
260 section.handle_input(&key(KeyCode::Enter), &tx);
261 assert!(!section.mouse, "mouse should toggle off");
262 assert!(section.use_nerd_fonts, "nerd fonts should be untouched");
263 assert!(section.update_check, "update_check should be untouched");
264 }
265
266 #[test]
267 fn down_three_times_then_right_increases_leader_timeout() {
268 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
269 let mut section = DisplaySection::new(true, true, true, 400);
270 for _ in 0..3 {
271 section.handle_input(&key(KeyCode::Down), &tx);
272 }
273 section.handle_input(&key(KeyCode::Right), &tx);
274 assert_eq!(section.leader_timeout_ms, 450);
275 }
276
277 #[test]
278 fn left_decreases_leader_timeout() {
279 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
280 let mut section = DisplaySection::new(true, true, true, 400);
281 for _ in 0..3 {
282 section.handle_input(&key(KeyCode::Down), &tx);
283 }
284 section.handle_input(&key(KeyCode::Left), &tx);
285 assert_eq!(section.leader_timeout_ms, 350);
286 }
287
288 #[test]
289 fn leader_timeout_clamps_at_zero() {
290 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
291 let mut section = DisplaySection::new(true, true, true, 20);
292 for _ in 0..3 {
293 section.handle_input(&key(KeyCode::Down), &tx);
294 }
295 section.handle_input(&key(KeyCode::Left), &tx);
296 assert_eq!(section.leader_timeout_ms, 0);
297 }
298
299 #[test]
300 fn leader_timeout_clamps_at_max() {
301 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
302 let mut section = DisplaySection::new(true, true, true, 1980);
303 for _ in 0..3 {
304 section.handle_input(&key(KeyCode::Down), &tx);
305 }
306 section.handle_input(&key(KeyCode::Right), &tx);
307 assert_eq!(section.leader_timeout_ms, 2000);
308 }
309
310 #[test]
311 fn left_right_do_nothing_off_the_leader_timeout_row() {
312 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
313 let mut section = DisplaySection::new(true, true, true, 400);
314 let state = section.handle_input(&key(KeyCode::Right), &tx);
315 assert_eq!(state, EventState::NotConsumed);
316 assert_eq!(section.leader_timeout_ms, 400);
317 }
318
319 #[test]
320 fn renders_leader_timeout_value() {
321 use ratatui::Terminal;
322 use ratatui::backend::TestBackend;
323 let backend = TestBackend::new(60, 10);
324 let mut terminal = Terminal::new(backend).unwrap();
325 let mut section = DisplaySection::new(true, true, true, 400);
326 let theme = Theme::gruvbox_dark();
327 terminal
328 .draw(|f| section.render(f, f.area(), &theme, false))
329 .unwrap();
330 let buf = terminal.backend().buffer().clone();
331 let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
332 assert!(flat.contains("400ms"), "expected the delay value: {flat}");
333 }
334
335 #[test]
336 fn renders_checked_when_enabled() {
337 use ratatui::Terminal;
338 use ratatui::backend::TestBackend;
339 let backend = TestBackend::new(40, 10);
340 let mut terminal = Terminal::new(backend).unwrap();
341 let mut section = DisplaySection::new(true, true, true, 400);
342 let theme = Theme::gruvbox_dark();
343 terminal
344 .draw(|f| section.render(f, f.area(), &theme, false))
345 .unwrap();
346 let buf = terminal.backend().buffer().clone();
347 let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
348 assert!(flat.contains("[x]"), "expected [x] when nerd fonts enabled");
349 }
350
351 #[test]
352 fn renders_unchecked_when_disabled() {
353 use ratatui::Terminal;
354 use ratatui::backend::TestBackend;
355 let backend = TestBackend::new(40, 10);
356 let mut terminal = Terminal::new(backend).unwrap();
357 let mut section = DisplaySection::new(false, true, true, 400);
358 let theme = Theme::gruvbox_dark();
359 terminal
360 .draw(|f| section.render(f, f.area(), &theme, false))
361 .unwrap();
362 let buf = terminal.backend().buffer().clone();
363 let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
364 assert!(
365 flat.contains("[ ]"),
366 "expected [ ] when nerd fonts disabled"
367 );
368 }
369}