1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
//! Help overlay — auto-generated from the command registry + keymap.
//!
//! `view.help` (bound to `?` by default) opens a scrollable overlay
//! listing every command grouped by its `Command.group`, with the
//! currently-bound chord(s) on each row. The keymap drives the
//! displayed chord, so user `[keys.*]` overrides appear here without
//! the help text needing to be hand-maintained.
//!
//! State is just `scroll: usize`. Esc / `?` close it.
//!
//! The renderer lives at `src/ui/help_overlay.rs`.
use crate::command::registry;
use crate::input::keymap::Keymap;
/// `None` on `App.help_overlay` ⇒ overlay closed.
#[derive(Debug, Clone, Default)]
pub struct HelpOverlayState {
/// First visible line in the rendered list. Bounded by the
/// renderer when paging.
pub scroll: usize,
/// Section names currently collapsed (don't render their
/// binding rows). Default: empty = all expanded. Per-session.
pub collapsed: std::collections::HashSet<String>,
/// #polish 2026-07-06 — case-insensitive substring filter over
/// binding titles + chord strings + section names. Empty ⇒ show
/// everything.
pub query: String,
/// `/` in the overlay focuses the input; typing appends; Esc
/// clears + unfocuses. Mirrors the picker/settings filter idiom.
pub filter_focused: bool,
}
/// One row in the help overlay — either a section header or a binding.
#[derive(Debug, Clone)]
pub enum HelpRow {
Section(&'static str),
Binding { keys: String, title: &'static str },
}
/// Build the displayed rows by walking the command registry, grouping
/// by `Command.group`, and resolving each command's currently-bound
/// chord(s) from `keymap`. Commands with no binding are still listed
/// (with an empty `keys` field) — they're reachable through the
/// palette / ex-commands.
pub fn build_help(keymap: &Keymap) -> Vec<HelpRow> {
// Reverse the keymap: command id → list of chord-specs.
let mut bindings: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (seq, id) in keymap.iter() {
bindings
.entry(id.to_string())
.or_default()
.push(crate::input::keymap::chord_seq_to_spec(seq));
}
for chords in bindings.values_mut() {
chords.sort();
}
// design-critic round-3 finding #7 2026-07-11 — mode-chip
// legend is only reachable via mouse hover. Prepend a "modes"
// section so the ? overlay documents the color coding
// + chip labels.
let mut rows: Vec<HelpRow> = Vec::new();
rows.push(HelpRow::Section("modes"));
for (chip, meaning) in [
("NORMAL", "vim normal mode (red)"),
("INSERT", "vim/standard editable (green)"),
("VISUAL", "vim visual — charwise (purple)"),
("V-LINE", "vim visual — linewise (purple)"),
("V-BLOCK", "vim visual — block/column (purple)"),
("REPLACE", "vim replace mode (orange)"),
("TREE", "file tree focused (blue)"),
("VIEW", "read-only pane focused (cyan)"),
("EDIT", "standard mode editing (green)"),
("PANEL", "right side panel focused (cyan)"),
] {
rows.push(HelpRow::Binding {
keys: chip.to_string(),
title: meaning,
});
}
// stress meter section — new users won't discover the chip
// since it's hidden on idle. Document it here + name the two
// spots it shows so users know where to look. 2026-07-12.
rows.push(HelpRow::Section("stress meter"));
for (chip, meaning) in [
(
"0-20",
"1 block, green — idle / smooth (hidden at exactly 0)",
),
("20-40", "2 blocks, yellow — slight load"),
("40-70", "3 blocks, orange — noticeable slowdown"),
("70-100", "4 blocks, red — mnml is stressed"),
(
"chip location",
"top-right cluster + bottom-right statusline (twin bars)",
),
(
"right-click",
"reset window · copy summary · toast the numbers",
),
] {
rows.push(HelpRow::Binding {
keys: chip.to_string(),
title: meaning,
});
}
// Stable section order — the registry yields commands in source
// order so the same group's commands cluster naturally. We just
// emit a section header the first time a new group appears.
let mut last_group: &str = "";
for cmd in registry().all() {
if cmd.group != last_group {
rows.push(HelpRow::Section(cmd.group));
last_group = cmd.group;
}
let keys = bindings
.get(cmd.id)
.map(|v| v.join(" · "))
.unwrap_or_default();
rows.push(HelpRow::Binding {
keys,
title: cmd.title,
});
}
rows
}
impl crate::app::App {
pub fn open_help_overlay(&mut self) {
self.help_overlay = Some(HelpOverlayState::default());
}
pub fn close_help_overlay(&mut self) {
self.help_overlay = None;
}
pub fn toggle_help_overlay(&mut self) {
if self.help_overlay.is_some() {
self.close_help_overlay();
} else {
self.open_help_overlay();
}
}
pub fn help_scroll(&mut self, delta: isize) {
if let Some(state) = self.help_overlay.as_mut() {
let new = (state.scroll as isize + delta).max(0) as usize;
state.scroll = new;
}
}
/// Toggle the collapsed state of a help section by name. Used
/// by the renderer's click handler.
pub fn toggle_help_section(&mut self, name: &str) {
if let Some(state) = self.help_overlay.as_mut() {
if state.collapsed.contains(name) {
state.collapsed.remove(name);
} else {
state.collapsed.insert(name.to_string());
}
// Reset scroll so the user doesn't lose their place when
// the layout shifts.
state.scroll = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn build_help_emits_section_headers_and_bindings() {
let cfg = Config::default();
let km = Keymap::build(&cfg);
let rows = build_help(&km);
// At least one section + one binding row.
let sections = rows
.iter()
.filter(|r| matches!(r, HelpRow::Section(_)))
.count();
let bindings = rows
.iter()
.filter(|r| matches!(r, HelpRow::Binding { .. }))
.count();
assert!(sections > 0, "expected at least one section header");
assert!(
bindings > 10,
"expected the command registry to be substantial"
);
}
#[test]
fn bindings_include_at_least_one_chord_for_keymapped_commands() {
let cfg = Config::default();
let km = Keymap::build(&cfg);
let rows = build_help(&km);
// Find any binding row whose `id` has a default key in the registry.
let any_with_key = rows.iter().any(|r| match r {
HelpRow::Binding { keys, .. } => !keys.is_empty(),
_ => false,
});
assert!(
any_with_key,
"expected at least one binding row to have a chord"
);
}
}