kimun_notes/components/
which_key.rs1use ratatui::Frame;
11use ratatui::layout::Rect;
12use ratatui::style::{Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15
16use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
17use crate::keys::leader::LeaderEngine;
18use crate::settings::themes::Theme;
19
20const CELL_WIDTH: u16 = 24;
22
23pub fn desired_height(engine: &LeaderEngine, width: u16) -> u16 {
29 let n = engine.current_node().display_children().len() as u16;
30 let cols = (width.saturating_sub(2) / CELL_WIDTH).max(1);
31 let grid_rows = n.div_ceil(cols);
32 grid_rows + 3 }
34
35pub fn render(
38 f: &mut Frame,
39 rect: Rect,
40 theme: &Theme,
41 engine: &LeaderEngine,
42 gateway_label: &str,
43) {
44 let inner = modal_chrome(
45 f,
46 rect,
47 theme,
48 ModalSpec {
49 border: Some(Style::default().fg(theme.focus_border.to_ratatui())),
50 bg: ModalBg::Hard,
51 ..Default::default()
52 },
53 );
54 if inner.height == 0 {
55 return;
56 }
57
58 let node = engine.current_node();
59 let keycap = Style::default()
60 .fg(theme.yellow.to_ratatui())
61 .add_modifier(Modifier::BOLD);
62 let muted = Style::default().fg(theme.gray.to_ratatui());
63 let caption_style = Style::default().fg(theme.fg_secondary.to_ratatui());
64
65 let mut pressed = format!(" {gateway_label}");
67 for c in engine.path() {
68 pressed.push(' ');
69 pressed.push(*c);
70 }
71 let controls = "Esc cancel · BkSp up ";
72 let controls_w = controls.len() as u16;
73 let header_cols = ratatui::layout::Layout::default()
74 .direction(ratatui::layout::Direction::Horizontal)
75 .constraints([
76 ratatui::layout::Constraint::Min(0),
77 ratatui::layout::Constraint::Length(controls_w),
78 ])
79 .split(Rect::new(inner.x, inner.y, inner.width, 1));
80 f.render_widget(
81 Paragraph::new(Line::from(vec![
82 Span::styled(pressed, keycap),
83 Span::styled(format!(" {}", node.label()), caption_style),
84 ])),
85 header_cols[0],
86 );
87 f.render_widget(
88 Paragraph::new(Line::from(Span::styled(controls, muted)))
89 .alignment(ratatui::layout::Alignment::Right),
90 header_cols[1],
91 );
92
93 let children = node.display_children();
95 if children.is_empty() {
96 return;
97 }
98 let cols = (inner.width / CELL_WIDTH).max(1) as usize;
99 let rows = children.len().div_ceil(cols);
100 let arrow = Span::styled(" → ", muted);
101 for (i, row) in children.iter().enumerate() {
102 let col = i / rows;
105 let grid_row = i % rows;
106 let y = inner.y + 1 + grid_row as u16;
107 if y >= inner.bottom() {
108 continue;
109 }
110 let x = inner.x + (col as u16) * CELL_WIDTH;
111 if x >= inner.right() {
112 continue;
113 }
114 let target_style = if row.is_group {
115 Style::default().fg(theme.aqua.to_ratatui())
116 } else {
117 Style::default().fg(theme.fg.to_ratatui())
118 };
119 let cell = Rect::new(x, y, CELL_WIDTH.min(inner.right() - x), 1);
120 f.render_widget(
121 Paragraph::new(Line::from(vec![
122 Span::styled(format!(" {}", row.keys), keycap),
123 arrow.clone(),
124 Span::styled(row.label.clone(), target_style),
125 ])),
126 cell,
127 );
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
149 fn desired_height_counts_collapsed_rows_not_raw_children() {
150 let engine = LeaderEngine::new();
151 let root = engine.current_node();
152 let raw = root.children().len() as u16;
153
154 let width = CELL_WIDTH;
155 assert_eq!(desired_height(&engine, width), 17); assert_ne!(desired_height(&engine, width), raw + 3);
158 }
159}