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, LeaderNode};
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.
25pub fn desired_height(engine: &LeaderEngine, width: u16) -> u16 {
26    let n = engine.current_node().children().len() as u16;
27    let cols = (width.saturating_sub(2) / CELL_WIDTH).max(1);
28    let grid_rows = n.div_ceil(cols);
29    grid_rows + 3 // top border + header + grid + bottom border
30}
31
32/// Render the overlay into `rect` (the caller positions it docked above the
33/// status bar, full width).
34pub fn render(
35    f: &mut Frame,
36    rect: Rect,
37    theme: &Theme,
38    engine: &LeaderEngine,
39    gateway_label: &str,
40) {
41    let inner = modal_chrome(
42        f,
43        rect,
44        theme,
45        ModalSpec {
46            border: Some(Style::default().fg(theme.focus_border.to_ratatui())),
47            bg: ModalBg::Hard,
48            ..Default::default()
49        },
50    );
51    if inner.height == 0 {
52        return;
53    }
54
55    let node = engine.current_node();
56    let keycap = Style::default()
57        .fg(theme.yellow.to_ratatui())
58        .add_modifier(Modifier::BOLD);
59    let muted = Style::default().fg(theme.gray.to_ratatui());
60    let caption_style = Style::default().fg(theme.fg_secondary.to_ratatui());
61
62    // ── Header: pressed keycaps · caption · right-aligned controls ─────────
63    let mut pressed = format!(" {gateway_label}");
64    for c in engine.path() {
65        pressed.push(' ');
66        pressed.push(*c);
67    }
68    let controls = "Esc cancel · BkSp up ";
69    let controls_w = controls.len() as u16;
70    let header_cols = ratatui::layout::Layout::default()
71        .direction(ratatui::layout::Direction::Horizontal)
72        .constraints([
73            ratatui::layout::Constraint::Min(0),
74            ratatui::layout::Constraint::Length(controls_w),
75        ])
76        .split(Rect::new(inner.x, inner.y, inner.width, 1));
77    f.render_widget(
78        Paragraph::new(Line::from(vec![
79            Span::styled(pressed, keycap),
80            Span::styled(format!("  {}", node.label()), caption_style),
81        ])),
82        header_cols[0],
83    );
84    f.render_widget(
85        Paragraph::new(Line::from(Span::styled(controls, muted)))
86            .alignment(ratatui::layout::Alignment::Right),
87        header_cols[1],
88    );
89
90    // ── Body: multi-column key → target grid ───────────────────────────────
91    let children = node.children();
92    if children.is_empty() {
93        return;
94    }
95    let cols = (inner.width / CELL_WIDTH).max(1) as usize;
96    let rows = children.len().div_ceil(cols);
97    let arrow = Span::styled(" → ", muted);
98    for (i, (key, child)) in children.iter().enumerate() {
99        // Column-major fill: read top-to-bottom within a column, like the
100        // spec mockup.
101        let col = i / rows;
102        let row = i % rows;
103        let y = inner.y + 1 + row as u16;
104        if y >= inner.bottom() {
105            continue;
106        }
107        let x = inner.x + (col as u16) * CELL_WIDTH;
108        if x >= inner.right() {
109            continue;
110        }
111        let target_style = match child {
112            LeaderNode::Group { .. } => Style::default().fg(theme.aqua.to_ratatui()),
113            LeaderNode::Leaf { .. } => Style::default().fg(theme.fg.to_ratatui()),
114        };
115        let cell = Rect::new(x, y, CELL_WIDTH.min(inner.right() - x), 1);
116        f.render_widget(
117            Paragraph::new(Line::from(vec![
118                Span::styled(format!(" {key}"), keycap),
119                arrow.clone(),
120                Span::styled(child.label().to_string(), target_style),
121            ])),
122            cell,
123        );
124    }
125}