lattice_mode/modes/help.rs
1//! `help-mode` -- minor mode that turns a markdown buffer into a
2//! help buffer (DESIGN.md §5.11). Composes with `markdown-mode`
3//! (the major), which carries the syntax pipeline + motion
4//! semantics. Help-mode adds:
5//!
6//! - `ReadOnly = true` (option contribution).
7//! - Link / anchor metadata parsing (today carried on the
8//! `HelpContent` bundle; future: contributed by help-mode's
9//! `on_activate`).
10//! - `<CR>` follow-link dispatch (gated on this minor being
11//! active).
12//! - The `:help` / `:describe-*` / `:apropos` / `:keymap` / etc.
13//! workflow commands (gated on this minor being active).
14//!
15//! Decoupling stance (M.4): the popup UI component is buffer-
16//! agnostic. It can render any buffer; help-mode-tagged buffers
17//! just happen to be the only popup content kind today. The
18//! user's display preference (popup / split / tab / minibuffer)
19//! is orthogonal to which mode the buffer carries.
20
21use std::sync::Arc;
22
23use lattice_config::OptionOverrideSet;
24use lattice_grammar::effect::Effect;
25
26use crate::{
27 CapabilitySet, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
28 keymap_entry,
29};
30
31/// `help-mode` — the minor that makes a (markdown-major) buffer a help
32/// buffer: read-only, gutterless, no-file, `<Esc>` dismisses it, and its
33/// motions run through the read-only help invocation runner. Activated by
34/// the host on `:help` / `:describe-*` / `:apropos` / `:keymap` / … views;
35/// see the module docs.
36pub struct HelpMode;
37
38impl HelpMode {
39 /// The canonical id, `"help-mode"` — what [`Mode::id`](crate::Mode::id)
40 /// returns. Use it to name this mode without an instance (activation,
41 /// `implies`, keymap layers, tests).
42 pub fn mode_id() -> ModeId {
43 ModeId::new("help-mode")
44 }
45}
46
47impl Mode for HelpMode {
48 type Guard = ();
49 fn id(&self) -> ModeId {
50 Self::mode_id()
51 }
52 fn kind(&self) -> ModeKind {
53 ModeKind::Minor
54 }
55 fn options(&self) -> OptionOverrideSet {
56 // `Wrap = false` (HP.3). It was `true` — "Bug 4: long help
57 // bodies should wrap at the pane width rather than overflow
58 // horizontally" — and that reasoning held while a help page was
59 // prose. It stopped holding once pages carried structure.
60 //
61 // Wrapping is a per-LINE transform with no idea what the line
62 // is part of, so a table row wider than the pane breaks in the
63 // middle of a cell and the column below it no longer lines up
64 // with anything. HP.1 aligns those columns by display width;
65 // wrapping then takes the alignment apart again on exactly the
66 // tables that most needed it — the wide ones. The same applies
67 // to the box-drawing menu mock-ups in the magit pages and to
68 // indented code samples, where a wrapped continuation reads as
69 // a new line at the wrong depth.
70 //
71 // The trade is real and worth stating: a long prose paragraph
72 // now runs off the right edge and needs horizontal scrolling
73 // (`zl` / `zh`, or `:set wrap` for that buffer). That is the
74 // lesser harm — prose that runs off the edge is still readable
75 // once scrolled, where a broken table is misinformation about
76 // which value belongs to which column. It is also the
77 // convention: `man`, `info` and Emacs `*Help*` all lay out to a
78 // fixed measure rather than reflow.
79 //
80
81 // `NoFile = true`: help buffers carry generated content
82 // (apropos lists, describe-* renders), not on-disk
83 // files; `:q` must not warn about unsaved changes.
84 //
85 // PU.1b-1a: help renders gutterless — `Number = false`
86 // (no line-number gutter) + `signcolumn = no` (no
87 // diagnostics / diff sign columns). These are plain option
88 // values: the renderer derives the gutter geometry from them
89 // and never knows it is painting help (a regular buffer with
90 // `:set nonu signcolumn=no` renders identically).
91 // IG.6: `IndentGuides = false`. A help page's leading whitespace is
92 // layout — table cells, box-drawing mock-ups, list continuation —
93 // not the indent structure of something being edited, so a rule
94 // down it claims a nesting that is not there. It is an option
95 // rather than a renderer check for the usual reason: a regular
96 // buffer with `:setlocal noindent-guides` renders identically, and
97 // neither peer learns what help is.
98 lattice_config::overrides! {
99 lattice_config::ReadOnly = true,
100 lattice_config::Wrap = false,
101 lattice_config::NoFile = true,
102 lattice_config::Number = false,
103 lattice_config::SignColumnOption = lattice_config::SignColumn::No,
104 lattice_config::core_options::IndentGuides = false,
105 }
106 }
107 fn required_capabilities(&self) -> CapabilitySet {
108 CapabilitySet::empty()
109 }
110 /// 2026-05-26: claim invocation dispatch for help panes via
111 /// `Editor::run_help_invocation`. Help is a *minor* mode
112 /// (`MarkdownMode` is the major), so the runner lookup walks
113 /// active minors first and finds this id before falling
114 /// through to the major.
115 fn invocation_runner(&self) -> Option<ModeId> {
116 Some(Self::mode_id())
117 }
118 /// `<Esc>` closes the help buffer — the mode owns both the chord and the
119 /// dismiss, rather than the host's input layer intercepting Esc for help
120 /// (the LM.4 file-tree pattern). Pushed at boot under
121 /// `MinorMode(help-mode)` by `translate_mode_keymaps` and gated to
122 /// help-active buffers by K.1.c. The command it names
123 /// (`action:help-dismiss`) is registered by [`register_help_mode_actions`];
124 /// its body emits [`Effect::DismissPopup`], which the host applies as the
125 /// right dismiss for the buffer's display — close the split pane help
126 /// opened, dismiss a floating popup, or restore an active-pane buffer.
127 fn keymap(&self) -> Keymap {
128 Keymap::from_entries(help_mode_keymap_entries())
129 }
130 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
131 Box::pin(async { Ok(()) })
132 }
133}
134
135/// The `<Esc>` → `action:help-dismiss` entry. Interned once; `Keymap::from_entries`
136/// resolves the `cmd` name against the command registry at boot.
137fn help_mode_keymap_entries() -> &'static [KeymapEntry] {
138 use std::sync::OnceLock;
139 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
140 ENTRIES
141 .get_or_init(|| {
142 vec![keymap_entry! {
143 mode: Normal, chord: "<Esc>",
144 doc: "help: close the popup / split pane",
145 cmd: "action:help-dismiss"
146 }]
147 })
148 .as_slice()
149}
150
151/// Register `help-mode`'s `action:help-dismiss` command so
152/// `translate_mode_keymaps` can resolve the `<Esc>` binding's `cmd` name. Its
153/// body emits [`Effect::DismissPopup`]; the host resolves HOW to dismiss (close
154/// the help-owned split pane, dismiss a floating popup, or restore the buffer
155/// active-pane help displaced) — the mode owns the chord + the decision, the
156/// pane/popup mechanism stays host-side behind the effect boundary. Called at
157/// boot beside `register_repl_mode_actions` while the registry is mutable; an
158/// unresolvable name would drop the whole binding with a warn.
159pub fn register_help_mode_actions(registry: &mut lattice_grammar::CommandRegistry) {
160 use lattice_grammar::registry::ActionSpec;
161 registry.register_action(
162 "action:help-dismiss",
163 "help: close the popup or split pane (mode-owned).",
164 ActionSpec {
165 apply: Arc::new(|_| Ok(Effect::DismissPopup)),
166 args_schema: vec![],
167 },
168 );
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn id_and_kind() {
177 assert_eq!(HelpMode.id(), HelpMode::mode_id());
178 assert_eq!(HelpMode::mode_id().as_str(), "help-mode");
179 assert_eq!(HelpMode.kind(), ModeKind::Minor);
180 }
181
182 /// Read one override's value back out of the set.
183 ///
184 /// The set is type-erased (`TypeId` + `Arc<dyn Any>`), so a test
185 /// that wants to assert a VALUE has to downcast. Worth the few
186 /// lines: the previous test only counted the overrides, which meant
187 /// it passed identically whether `Wrap` was `true` or `false` —
188 /// it could not have caught HP.3 flipping it in either direction.
189 fn value_of<D: lattice_config::OptionDecl>(opts: &OptionOverrideSet) -> Option<D::Value>
190 where
191 D::Value: Clone + Send + Sync + 'static,
192 {
193 opts.iter()
194 .find(|o| o.option_type_id == std::any::TypeId::of::<D>())
195 .and_then(|o| o.value.clone().downcast::<D::Value>().ok())
196 .map(|v| (*v).clone())
197 }
198
199 #[test]
200 fn contributes_read_only_wrap_and_no_file() {
201 // NoFile keeps `:q` quiet when a help pane is the last buffer
202 // open. PU.1b-1a adds Number = false + signcolumn = no so help
203 // renders gutterless via the option-driven path.
204 let opts = HelpMode.options();
205 // Assert by IDENTITY, not by count. The count assertion that used
206 // to stand here went stale the moment IG.6 added the
207 // indent-guides opt-out, and a bare number could not say which
208 // option was missing or extra — the same failure mode the doc
209 // comment above already calls out for `Wrap`.
210 let ids: Vec<std::any::TypeId> = opts.iter().map(|o| o.option_type_id).collect();
211 for (want, why) in [
212 (
213 std::any::TypeId::of::<lattice_config::ReadOnly>(),
214 "help is read-only",
215 ),
216 (
217 std::any::TypeId::of::<lattice_config::Wrap>(),
218 "help does not wrap (HP.3)",
219 ),
220 (
221 std::any::TypeId::of::<lattice_config::NoFile>(),
222 "`:q` stays quiet on the last help pane",
223 ),
224 (
225 std::any::TypeId::of::<lattice_config::Number>(),
226 "help renders gutterless",
227 ),
228 (
229 std::any::TypeId::of::<lattice_config::SignColumnOption>(),
230 "help reserves no sign column",
231 ),
232 ] {
233 assert!(ids.contains(&want), "{why}");
234 }
235 assert_eq!(value_of::<lattice_config::ReadOnly>(&opts), Some(true));
236 assert_eq!(value_of::<lattice_config::NoFile>(&opts), Some(true));
237 assert_eq!(value_of::<lattice_config::Number>(&opts), Some(false));
238 }
239
240 /// HP.3: **help does not wrap**, and the value is asserted rather
241 /// than counted.
242 ///
243 /// Wrapping is a per-line transform that knows nothing about what
244 /// the line belongs to, so a table row wider than the pane breaks
245 /// mid-cell and the columns below stop lining up — undoing HP.1 on
246 /// exactly the wide tables that needed aligning, and mangling the
247 /// box-drawing menu mock-ups in the magit pages the same way.
248 #[test]
249 fn help_does_not_wrap() {
250 assert_eq!(
251 value_of::<lattice_config::Wrap>(&HelpMode.options()),
252 Some(false),
253 "help-mode must contribute `Wrap = false`: a wrapped table \
254 row misreports which value belongs to which column, which \
255 is worse than prose needing horizontal scroll",
256 );
257 }
258
259 #[test]
260 fn contributes_read_only() {
261 let opts = HelpMode.options();
262 assert!(!opts.is_empty());
263 }
264}