lattice_mode/foldable_view_mode.rs
1//! `foldable-view-mode` — the one place `<Tab>` folds the block at point.
2//!
3//! ## Why a shared minor and not a chord per view
4//!
5//! A grouped, read-only view folds by blocks, and cycling the block at the
6//! cursor is a property of *that class of view* rather than of any one of
7//! them. By the "shared behaviour is a minor mode, never a copied keymap"
8//! standing rule the chord belongs here once.
9//!
10//! It did not start that way, and the shape of the drift is the argument.
11//! As of 2026-09-01 `magit-nav-mode` bound both chords, and its own module
12//! doc already stated the generalisation — *"navigating sections and folding
13//! are meaningful wherever there are sections"* — while scoping it to magit.
14//! `org-agenda-mode` then grew an independent copy. Meanwhile **project
15//! search, the LSP references view, `*problems*` and `*compilation*` had
16//! neither chord**: four foldable grouped views with no way to collapse a
17//! block, and nobody noticed, because a gap in a copied set does not announce
18//! itself. That is the same failure `refreshable-view-mode` closed for `gr`,
19//! one chord later.
20//!
21//! ## The split
22//!
23//! Exactly [`refreshable_view_mode`](crate::refreshable_view_mode)'s:
24//!
25//! - **`<S-Tab>` is owned outright.** Cycling every fold in the buffer is
26//! generic — magit's `action:magit-cycle-sections` body was literally
27//! `Effect::AppAction(AppEffect::CycleFoldsGlobal)`, the same expression
28//! this mode's own action evaluates to. There is nothing per-view to
29//! declare, so there is no declaration.
30//! - **`<Tab>` is a target, not a body.** A view that wants the plain
31//! cycle-the-fold-at-cursor names [`FOLD_TOGGLE_DEFAULT_ACTION`]; a view
32//! with a genuine specialisation names its own action. Magit's is real: on
33//! a status file line the first press expands the diff so `<Tab>` and `=`
34//! agree, and everywhere else it is the plain toggle.
35//!
36//! Resolution is host-side (`Editor::resolve_fold_toggle_action`) because it
37//! needs the buffer's active-mode set, which lives on the editor rather than
38//! in the `ServiceRegistry` — the same reason the refresh resolution lives
39//! there.
40//!
41//! ## Activation
42//!
43//! Automatic: a mode returning `Some` from [`Mode::fold_toggle_action`] pulls
44//! this minor in through the implies cascade. One line per view, and no second
45//! thing to remember — forgetting it would kill the chord exactly as silently
46//! as the copied keymaps did.
47//!
48//! [`ActivationPolicy::Manual`] so it never auto-attaches to ordinary buffers.
49//! **`<Tab>` in a document buffer is the terminal alias for `<C-i>`,
50//! jump-list-forward, and must stay that way.** Taking it costs that motion in
51//! the views that opt in, which is the deliberate trade: in a grouped
52//! read-only view you navigate with `<CR>` and `]]`, and folding a block is
53//! the thing you reach for.
54
55use std::sync::{Arc, OnceLock};
56
57use crate::registry::ModeRegistry;
58use crate::{
59 ActivationPolicy, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
60 keymap_entry,
61};
62
63/// The canonical command name `<Tab>` resolves to. The host intercepts this
64/// id, resolves the active modes' declared fold-toggle action, and dispatches
65/// *that*.
66pub const VIEW_FOLD_TOGGLE_ACTION: &str = "action:view-fold-toggle";
67
68/// The body a view names when it wants the ordinary behaviour: cycle the fold
69/// containing the cursor. Named explicitly rather than defaulted so that
70/// "this view folds" is a statement the view makes, not one it falls into.
71pub const FOLD_TOGGLE_DEFAULT_ACTION: &str = "action:view-fold-toggle-default";
72
73/// `<S-Tab>`. Owned outright — see the module doc.
74pub const VIEW_FOLD_CYCLE_ACTION: &str = "action:view-fold-cycle";
75
76/// `foldable-view-mode` minor. Two keymap entries, no per-buffer resources
77/// (`Guard = ()`), and no `<Tab>` handler — that body belongs to each view.
78pub struct FoldableViewMode;
79
80impl FoldableViewMode {
81 /// The canonical id, `"foldable-view-mode"` — what [`Mode::id`](crate::Mode::id)
82 /// returns. Use it to name this mode without an instance (activation,
83 /// `implies`, keymap layers, tests).
84 pub fn mode_id() -> ModeId {
85 ModeId::new("foldable-view-mode")
86 }
87}
88
89impl Mode for FoldableViewMode {
90 type Guard = ();
91
92 fn id(&self) -> ModeId {
93 Self::mode_id()
94 }
95
96 fn kind(&self) -> ModeKind {
97 ModeKind::Minor
98 }
99
100 /// Never auto-activated by policy — it arrives through the implies
101 /// cascade when a mode declares `fold_toggle_action()`. Keeping this
102 /// `Manual` is what stops `<Tab>` shadowing jump-list-forward in ordinary
103 /// document buffers.
104 fn activation_policy(&self) -> ActivationPolicy {
105 ActivationPolicy::Manual
106 }
107
108 fn keymap(&self) -> Keymap {
109 Keymap::from_entries(foldable_view_keymap_entries())
110 }
111
112 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
113 Box::pin(async { Ok(()) })
114 }
115}
116
117fn foldable_view_keymap_entries() -> &'static [KeymapEntry] {
118 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
119 ENTRIES.get_or_init(|| {
120 vec![
121 keymap_entry! {
122 mode: Normal,
123 chord: "<Tab>",
124 doc: "Fold or unfold the block at the cursor",
125 cmd: "action:view-fold-toggle"
126 },
127 keymap_entry! {
128 mode: Normal,
129 chord: "<S-Tab>",
130 doc: "Fold or unfold every block",
131 cmd: "action:view-fold-cycle"
132 },
133 ]
134 })
135}
136
137/// Register [`FoldableViewMode`] in `registry`. Called by
138/// [`register_foundation_modes`](crate::register_foundation_modes); it must
139/// be registered for the implies cascade to pull it in (an unregistered
140/// shared minor is logged at `debug!` and the chord simply does not bind).
141///
142/// # Panics
143///
144/// If `foldable-view-mode` is already registered.
145pub fn register_foldable_view_mode(registry: &mut ModeRegistry) {
146 registry
147 .register(FoldableViewMode)
148 .expect("foldable-view-mode must register without conflict");
149}
150
151/// Register the three action names.
152///
153/// `action:view-fold-toggle` has a dead body for
154/// [`refreshable_view_mode`](crate::refreshable_view_mode)'s reason: the host
155/// intercepts the `CommandId` and dispatches whatever the active modes
156/// declared, so this apply never runs. The other two are real.
157pub fn register_foldable_view_actions(registry: &mut lattice_grammar::CommandRegistry) {
158 use lattice_grammar::app_effect::AppEffect;
159 use lattice_grammar::effect::Effect;
160 use lattice_grammar::registry::ActionSpec;
161
162 registry.register_action(
163 VIEW_FOLD_TOGGLE_ACTION,
164 "Fold or unfold the block at the cursor (resolves to the active mode's \
165 declared fold-toggle action).",
166 ActionSpec {
167 apply: Arc::new(|_| Ok(Effect::None)),
168 args_schema: vec![],
169 },
170 );
171 registry.register_action(
172 FOLD_TOGGLE_DEFAULT_ACTION,
173 "Fold or unfold the block at the cursor.",
174 ActionSpec {
175 apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::CycleFoldAtCursor))),
176 args_schema: vec![],
177 },
178 );
179 registry.register_action(
180 VIEW_FOLD_CYCLE_ACTION,
181 "Fold or unfold every block in this view.",
182 ActionSpec {
183 apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::CycleFoldsGlobal))),
184 args_schema: vec![],
185 },
186 );
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn mode_id_uses_the_mode_suffix() {
195 assert_eq!(FoldableViewMode::mode_id().as_str(), "foldable-view-mode");
196 }
197
198 #[test]
199 fn is_a_manual_minor() {
200 let m = FoldableViewMode;
201 assert_eq!(m.kind(), ModeKind::Minor);
202 assert!(matches!(m.activation_policy(), ActivationPolicy::Manual));
203 }
204
205 /// Two chords, or the shared-ness is a lie.
206 #[test]
207 fn binds_tab_and_shift_tab() {
208 let entries = foldable_view_keymap_entries();
209 assert_eq!(entries.len(), 2);
210 assert_eq!(entries[0].chord, "<Tab>");
211 assert_eq!(entries[0].command, Some(VIEW_FOLD_TOGGLE_ACTION));
212 assert_eq!(entries[1].chord, "<S-Tab>");
213 assert_eq!(entries[1].command, Some(VIEW_FOLD_CYCLE_ACTION));
214 }
215
216 /// The `<Tab>` body belongs to each view; a handler here would be the
217 /// copied-keymap problem wearing a different hat.
218 #[test]
219 fn contributes_no_action_handler() {
220 assert!(FoldableViewMode.action_handlers().is_empty());
221 }
222
223 /// It does not declare a fold toggle of its own — otherwise it would pull
224 /// itself into the implies cascade.
225 #[test]
226 fn declares_no_fold_toggle_itself() {
227 assert_eq!(FoldableViewMode.fold_toggle_action(), None);
228 }
229}