Skip to main content

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}