Skip to main content

kimun_notes/components/
which_key.rs

1//! The **which-key overlay** (spec §8b) — the popup docked above the status
2//! bar that documents the pending leader sequence. It renders the node the
3//! `LeaderEngine` currently sits on, so it can never drift from the tree:
4//! same data, two surfaces.
5//!
6//! Reveal policy: hidden while a sequence is typed fluently; shown once the
7//! user hesitates past `leader_timeout_ms`. Hidden the instant the sequence
8//! fires or cancels (the engine simply stops being pending).
9
10use 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
20/// Minimum column width for a `key → target` cell.
21const CELL_WIDTH: u16 = 24;
22
23/// Rows the overlay needs for the current node (header + grid + borders).
24/// The caller carves this out of the area directly above the status bar.
25/// Counts `display_children()`, the same collapsed rows `render` lays out —
26/// counting raw `children()` here would reserve extra blank rows wherever a
27/// digit run (or any future collapse) shrinks the grid.
28pub 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 // top border + header + grid + bottom border
33}
34
35/// Render the overlay into `rect` (the caller positions it docked above the
36/// status bar, full width).
37pub 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    // ── Header: pressed keycaps · caption · right-aligned controls ─────────
66    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    // ── Body: multi-column key → target grid ───────────────────────────────
94    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        // Column-major fill: read top-to-bottom within a column, like the
103        // spec mockup.
104        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    /// `desired_height` must size the grid off the same rows `render` draws
136    /// — `display_children()` — not the raw `children()` count. At the root
137    /// the nine pinned-note digit leaves collapse to one row, so sizing off
138    /// the raw count would reserve several rows nothing draws into.
139    ///
140    /// The `17` is a literal, hand-derived pin (not re-derived from
141    /// `display_children()`, or the assertion could pass even if the
142    /// collapse silently regressed): the root has 13 non-digit children
143    /// (`f n l o g v w m t a p q ?`) plus the nine `1`-`9` pinned-note
144    /// leaves, which collapse to one row — 14 display rows. `CELL_WIDTH` is
145    /// narrower than `2 × CELL_WIDTH`, so `desired_height` computes a single
146    /// column and `grid_rows` equals the row count directly: 14 rows + 3
147    /// (top border + header + bottom border) = 17.
148    #[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); // 14 collapsed root rows + 3 chrome
156        // Sizing off the raw, uncollapsed count would have asked for more.
157        assert_ne!(desired_height(&engine, width), raw + 3);
158    }
159}