Skip to main content

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}