lattice_multibuffer/providers/narrow.rs
1//! N.1.1 (2026-06-10): **narrow mode** — a focused, editable
2//! one-excerpt multibuffer view of a region of a source buffer.
3//!
4//! Design: `docs/dev/architecture/narrow-mode.md`. Slice plan:
5//! `docs/dev/operations/slice-plans/narrow-mode.md` (N.1.1).
6//!
7//! Narrow is a *one-excerpt* multibuffer + a marker minor mode.
8//! There is no `BufferKind::Narrow` — the view is a regular
9//! `BufferKind::Multibuffer` and reuses every M-series primitive
10//! (M.3 edit propagation, M.4 live source updates, K.4.7 per-excerpt
11//! syntax). Edits in the narrow view propagate to the source buffer;
12//! `:widen` closes the view, leaving the source (with its edits)
13//! open.
14//!
15//! Unlike `providers::search`, narrow is **not** feature-gated — it's
16//! a first-class built-in primitive — and it carries no async scan,
17//! no service, and no per-view state beyond the excerpt itself.
18//!
19//! Entry is the `:narrow [{range}]` ex-command (and, from N.1.3, the
20//! `zn` operator). The ex-command emits `AppEffect::NarrowTrigger`;
21//! the host arm resolves the range, fetches the active buffer's
22//! handle, and calls [`create_narrow_view`].
23
24use std::collections::HashMap;
25use std::sync::Arc;
26
27use lattice_config::OptionOverrideSet;
28use lattice_core::{BufferFlags, BufferId};
29use lattice_grammar::{CommandRegistry, CommandRegistryHandle};
30use lattice_mode::{
31 CapabilitySet, Keymap, LifecycleFuture, Mode, ModeActivator, ModeContext, ModeId, ModeKind,
32 ModeRegistry,
33};
34use lattice_runtime::Document;
35use lattice_syntax::LangRegistry;
36
37use crate::registry::MultibufferRegistryHandle;
38use crate::view::create_multibuffer_view;
39use crate::{Excerpt, HeaderlineStatus};
40
41// ─────────────────────────────────────────────────────────────────
42// NarrowMode — identity marker for narrow views
43// ─────────────────────────────────────────────────────────────────
44
45/// `narrow-mode` — the provider-minor activated on a narrow
46/// view. In N.1.1 it is a pure identity marker: a multibuffer with
47/// this minor active IS a narrow view (distinguished from search /
48/// diff multibuffers), which the host's `:widen` guard and N.1.5's
49/// stacking logic read.
50///
51/// N.1.1.b adds the in-view surface (`q` → widen chord, `:w`
52/// source-save override) here; for now `on_activate` is a no-op so
53/// the marker is cheap.
54pub struct NarrowMode;
55
56impl NarrowMode {
57 pub fn mode_id() -> ModeId {
58 ModeId::new("narrow-mode")
59 }
60}
61
62/// RAII guard for `NarrowMode`. Unit in N.1.1 (no
63/// subscriptions / action handlers yet); becomes a `Vec` of
64/// `ActionHandlerRegistration` when the `q` / `:w` surface lands.
65pub struct NarrowModeGuard;
66
67impl Mode for NarrowMode {
68 type Guard = NarrowModeGuard;
69
70 fn id(&self) -> ModeId {
71 Self::mode_id()
72 }
73 fn kind(&self) -> ModeKind {
74 ModeKind::Minor
75 }
76 fn options(&self) -> OptionOverrideSet {
77 // Narrow views are EDITABLE — edits propagate to the source
78 // via M.3. No ReadOnly override.
79 OptionOverrideSet::new()
80 }
81 fn required_capabilities(&self) -> CapabilitySet {
82 CapabilitySet::empty()
83 }
84 fn keymap(&self) -> Keymap {
85 // N.1.1: no contributed chords yet (the `q` → widen binding
86 // lands in N.1.1.b once `action:narrow-widen` is registered).
87 Keymap::default()
88 }
89
90 // RV.3 (2026-08-10): narrow declares NO refresh action, and that is
91 // a decision rather than an omission.
92 //
93 // A narrow view is one excerpt over a **live** buffer, and
94 // `create_multibuffer_view` subscribes it to the source's
95 // `DocumentChanged` events — so it recomposes as the source is
96 // edited and is never stale. There is nothing a refresh could do
97 // that has not already happened.
98 //
99 // Contrast `providers::problems`, which reads its sources from disk
100 // into fresh handles at open time and renders a snapshot of the
101 // error list: both can drift, so it declares one.
102 //
103 // Leaving this `None` is now safe to state plainly, because the
104 // shared `gr` echoes "nothing to refresh here" instead of
105 // swallowing the key — the absence is spoken, which is precisely
106 // what pre-RV.1 narrow could not do.
107
108 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
109 Box::pin(async move { Ok(NarrowModeGuard) })
110 }
111}
112
113// ─────────────────────────────────────────────────────────────────
114// create_narrow_view — the shared sink
115// ─────────────────────────────────────────────────────────────────
116
117/// Allocate a one-excerpt multibuffer view over
118/// `[start_line, end_line]` (inclusive, 0-based) of `source_id` and
119/// activate [`NarrowMode`] on it. Returns the new view's
120/// `BufferId`.
121///
122/// `source_handle` is the *existing* source buffer's document handle
123/// (an `Arc` clone) — the narrow view shares it, so edits in the
124/// narrow view and the original pane stay live-synced (M.4). `label`
125/// is shown in the headerline (`"[narrow] <label> L<start+1>–<end+1>"`);
126/// pass the file name or a symbol name when known, else `""`.
127///
128/// Every entry surface (`:narrow`, Visual, the `zn` operator) funnels
129/// through here.
130pub fn create_narrow_view(
131 activator: &mut dyn ModeActivator,
132 source_id: BufferId,
133 source_handle: Arc<dyn Document>,
134 start_line: u32,
135 end_line: u32,
136 label: &str,
137 registry: CommandRegistryHandle,
138 lang_registry: Option<Arc<LangRegistry>>,
139) -> BufferId {
140 let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
141 sources.insert(source_id, source_handle);
142 let excerpt = Excerpt::new(source_id, start_line, end_line);
143
144 let name = if label.is_empty() {
145 format!("*narrow:L{}–{}*", start_line + 1, end_line + 1)
146 } else {
147 format!("*narrow:{label}*")
148 };
149
150 let view_id = create_multibuffer_view(
151 activator,
152 sources,
153 vec![excerpt],
154 Some(name),
155 BufferFlags::default(),
156 registry,
157 lang_registry,
158 crate::FoldGrouping::SourceFile,
159 );
160
161 // Set the sticky headerline. Narrow is instantaneous — no
162 // InProgress phase, straight to Complete.
163 if let Some(mb_reg) = activator.services().get::<MultibufferRegistryHandle>()
164 && let Some(view) = mb_reg.handle(view_id)
165 {
166 let summary = if label.is_empty() {
167 format!("[narrow] L{}–{}", start_line + 1, end_line + 1)
168 } else {
169 format!("[narrow] {label} L{}–{}", start_line + 1, end_line + 1)
170 };
171 view.set_headerline(HeaderlineStatus::Complete {
172 summary,
173 emphasis: None,
174 });
175 }
176
177 activator.activate_minor_by_id(view_id, NarrowMode::mode_id());
178 view_id
179}
180
181// ─────────────────────────────────────────────────────────────────
182// Boot integration
183// ─────────────────────────────────────────────────────────────────
184
185/// Boot helper — register the narrow provider-minor mode. Called
186/// from `lattice-host::editor_boot` alongside the other multibuffer
187/// mode registrations.
188pub fn register_narrow_mode(mode_registry: &mut ModeRegistry) {
189 mode_registry
190 .register(NarrowMode)
191 .expect("narrow-mode registers without conflict at boot");
192}
193
194/// Boot helper — register the `:narrow` + `:widen` ex-commands.
195///
196/// `:narrow` accepts an optional `{start},{end}` line range
197/// (`accepts_range`). The host resolves the range against the active
198/// document (the apply context has no document), so the apply just
199/// forwards the raw [`lattice_grammar::range::Range`] through
200/// `AppEffect::NarrowTrigger`. `:widen` emits `AppEffect::NarrowWiden`,
201/// which the host guards to narrow views before closing.
202pub fn register_narrow_ex_commands(registry: &mut CommandRegistry) {
203 use lattice_grammar::app_effect::AppEffect;
204 use lattice_grammar::args::Args;
205 use lattice_grammar::command::LatencyClass;
206 use lattice_grammar::effect::Effect;
207 use lattice_grammar::registry::{ExCommandSpec, SurfaceForm};
208
209 registry.register_ex_command(
210 "narrow",
211 "Narrow the editing surface to a region: `:narrow` (the cursor's \
212 paragraph) or `:{start},{end}narrow`. The region opens as a focused, \
213 editable view; edits propagate to the source file. `:widen` restores \
214 the full buffer.",
215 ExCommandSpec {
216 latency_class: LatencyClass::Reflex,
217 accepts_bang: false,
218 accepts_range: true,
219 parse_args: Arc::new(|_s: &str, _bang: bool| Ok(Args::None)),
220 apply: Arc::new(|ctx| {
221 Ok(Effect::AppAction(AppEffect::NarrowTrigger {
222 range: ctx.range.clone(),
223 }))
224 }),
225 args_schema: vec![],
226 surface_form: SurfaceForm::Keyword,
227 },
228 );
229
230 registry.register_ex_command(
231 "widen",
232 "Close the active narrow view, restoring the full source buffer.",
233 ExCommandSpec {
234 latency_class: LatencyClass::Reflex,
235 accepts_bang: false,
236 accepts_range: false,
237 parse_args: Arc::new(|_s: &str, _bang: bool| Ok(Args::None)),
238 apply: Arc::new(|_ctx| Ok(Effect::AppAction(AppEffect::NarrowWiden))),
239 args_schema: vec![],
240 surface_form: SurfaceForm::Keyword,
241 },
242 );
243}
244
245/// N.1.3 (2026-06-10): register the **`zn` narrow operator** into the
246/// grammar's `CommandRegistry` and return its `OperatorId`.
247///
248/// The operator is *universal* — you narrow from any editable buffer,
249/// like `d` / `y` / `c`. Per the mode-ownership split, this crate owns
250/// the operator SPEC + `apply`; the host wires the `zn` chord to the
251/// returned `OperatorId` at the universal operator-pending layer
252/// (operator-pending composition needs the host-resolved `Builtins`,
253/// which this crate can't reach). The narrow-VIEW surface
254/// (`:widen` / `:w` / `q`) stays with `NarrowMode`.
255///
256/// `apply` reads the resolved `OperatorContext.range` (the span the
257/// following motion / text object produced), converts it to an
258/// inclusive whole-line span, and emits
259/// `Effect::AppAction(AppEffect::NarrowLines { .. })`; the host arm
260/// narrows the active buffer to that span via `create_narrow_view`.
261pub fn register_narrow_operator(registry: &mut CommandRegistry) -> lattice_grammar::OperatorId {
262 use lattice_grammar::app_effect::AppEffect;
263 use lattice_grammar::effect::Effect;
264 use lattice_grammar::registry::{OperatorContext, OperatorSpec};
265
266 registry.register_operator(
267 "operator:narrow",
268 "Narrow the editing surface to the {motion}/{text-object} that follows \
269 `zn` — a focused, editable view of that region. `znn` narrows the \
270 current line; `znip` a paragraph; `znaf` a function. `:widen` restores.",
271 OperatorSpec {
272 repeatable: false,
273 blockwise_per_row: false,
274 post_motion_char: false,
275 args_schema: vec![],
276 apply: Arc::new(|ctx: &mut OperatorContext| {
277 let (start_line, end_line) = lattice_grammar::range::span_to_whole_lines(
278 ctx.range.start.line,
279 ctx.range.start.byte,
280 ctx.range.end.line,
281 ctx.range.end.byte,
282 );
283 Ok(Effect::AppAction(AppEffect::NarrowLines {
284 start_line,
285 end_line,
286 }))
287 }),
288 },
289 )
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn register_narrow_operator_registers_the_operator() {
298 let mut registry = CommandRegistry::new();
299 let _op = register_narrow_operator(&mut registry);
300 assert!(registry.id_by_name("operator:narrow").is_some());
301 }
302}