Skip to main content

lattice_multibuffer/
motions.rs

1//! M.2.b.3 (2026-06-01): excerpt-jump motions.
2//!
3//! Four motions register against `lattice-grammar`'s
4//! `CommandRegistry` and bind to `]e` / `[e` / `]E` / `[E` in
5//! `multibuffer-mode`'s keymap layer:
6//!
7//! | Chord | Motion id                          | Behaviour                                     |
8//! |-------|------------------------------------|-----------------------------------------------|
9//! | `]e`  | `multibuffer.next-excerpt-start`   | Cursor to first row of next excerpt           |
10//! | `[e`  | `multibuffer.prev-excerpt-start`   | Cursor to first row of prev excerpt           |
11//! | `]E`  | `multibuffer.next-file-boundary`   | Cursor to next excerpt with a different source |
12//! | `[E`  | `multibuffer.prev-file-boundary`   | Cursor to prev excerpt with a different source |
13//!
14//! Handlers capture an `Arc<MultibufferRegistryHandle>` at
15//! registration time, look up the typed handle via
16//! `ctx.buffer_id`, and compute target rows from the captured
17//! excerpts. Operators (`d`, `c`, `y`) compose with these motions
18//! automatically — they fall out of `lattice-grammar`'s standard
19//! operator+motion machinery, no per-motion plumbing.
20//!
21//! No-op behaviour: cursor stays put when there is no
22//! next / prev excerpt (e.g. cursor in the last excerpt, `]e`
23//! has no target).
24//!
25//! See `docs/dev/architecture/multibuffer-views.md` §3.7 +
26//! slice plan M.2.b.3.
27
28use lattice_core::BufferId;
29use lattice_grammar::registry::{MotionContext, MotionResult};
30use lattice_grammar::{
31    CheckCancelled, CommandError, CommandRegistry, GrammarResult, MotionId, MotionSpec,
32};
33use lattice_protocol::position::Position;
34use std::sync::Arc;
35
36use crate::Excerpt;
37use crate::registry::MultibufferRegistryHandle;
38
39/// The four motion ids registered by [`register_multibuffer_motions`].
40/// Boot wiring threads this struct into the keymap-layer push so
41/// chord bindings reach the right motion ids.
42#[derive(Debug, Clone, Copy)]
43pub struct MultibufferMotionIds {
44    pub next_excerpt_start: MotionId,
45    pub prev_excerpt_start: MotionId,
46    pub next_file_boundary: MotionId,
47    pub prev_file_boundary: MotionId,
48}
49
50/// Register the four excerpt-jump motions against `registry` and
51/// return their ids. Handlers capture `mb_registry` (cheap Arc
52/// clone) so they reach the typed view handle via
53/// `ctx.buffer_id` at dispatch time.
54///
55/// Lives in `lattice-multibuffer` (the crate that owns the
56/// excerpt data model). Boot wiring in `lattice-host` calls
57/// this after `crate::actions::populate(&mut registry, ...)`.
58pub fn register_multibuffer_motions(
59    registry: &mut CommandRegistry,
60    mb_registry: MultibufferRegistryHandle,
61) -> MultibufferMotionIds {
62    let mb_a = mb_registry.clone();
63    let next_excerpt_start = registry.register_motion(
64        "multibuffer.next-excerpt-start",
65        "Move cursor to the first row of the next excerpt (`]e`).",
66        MotionSpec {
67            curswant: lattice_grammar::CurswantEffect::default(),
68            jump: true,
69            exclusive: false,
70            apply: Arc::new(move |ctx| handle_next_excerpt_start(ctx, &mb_a)),
71            args_schema: Vec::new(),
72        },
73    );
74
75    let mb_b = mb_registry.clone();
76    let prev_excerpt_start = registry.register_motion(
77        "multibuffer.prev-excerpt-start",
78        "Move cursor to the first row of the previous excerpt (`[e`).",
79        MotionSpec {
80            curswant: lattice_grammar::CurswantEffect::default(),
81            jump: true,
82            exclusive: false,
83            apply: Arc::new(move |ctx| handle_prev_excerpt_start(ctx, &mb_b)),
84            args_schema: Vec::new(),
85        },
86    );
87
88    let mb_c = mb_registry.clone();
89    let next_file_boundary = registry.register_motion(
90        "multibuffer.next-file-boundary",
91        "Move cursor to the next excerpt whose `source` BufferId differs from the current excerpt's (`]E`).",
92        MotionSpec {
93            curswant: lattice_grammar::CurswantEffect::default(),
94            jump: true,
95            exclusive: false,
96            apply: Arc::new(move |ctx| handle_next_file_boundary(ctx, &mb_c)),
97            args_schema: Vec::new(),
98        },
99    );
100
101    let mb_d = mb_registry;
102    let prev_file_boundary = registry.register_motion(
103        "multibuffer.prev-file-boundary",
104        "Move cursor to the previous excerpt whose `source` BufferId differs from the current excerpt's (`[E`).",
105        MotionSpec {
106            curswant: lattice_grammar::CurswantEffect::default(),
107            jump: true,
108            exclusive: false,
109            apply: Arc::new(move |ctx| handle_prev_file_boundary(ctx, &mb_d)),
110            args_schema: Vec::new(),
111        },
112    );
113
114    MultibufferMotionIds {
115        next_excerpt_start,
116        prev_excerpt_start,
117        next_file_boundary,
118        prev_file_boundary,
119    }
120}
121
122// ──────────────────────────────────────────────────────────────
123// Motion handler shells: look up the typed handle, fall through
124// to the pure helpers, wrap into MotionResult.
125// ──────────────────────────────────────────────────────────────
126
127fn handle_next_excerpt_start(
128    ctx: &MotionContext,
129    mb: &MultibufferRegistryHandle,
130) -> GrammarResult<MotionResult> {
131    let excerpts = excerpts_for_buffer(mb, ctx)?;
132    let count = ctx.count.get().max(1);
133    let target_row =
134        next_excerpt_start_row(&excerpts, ctx.from.line, count).unwrap_or(ctx.from.line);
135    Ok(MotionResult {
136        curswant: None,
137        target: Position::new(target_row, 0),
138        linewise: false,
139        exclusive: None,
140        notice: None,
141    })
142}
143
144fn handle_prev_excerpt_start(
145    ctx: &MotionContext,
146    mb: &MultibufferRegistryHandle,
147) -> GrammarResult<MotionResult> {
148    let excerpts = excerpts_for_buffer(mb, ctx)?;
149    let count = ctx.count.get().max(1);
150    let target_row =
151        prev_excerpt_start_row(&excerpts, ctx.from.line, count).unwrap_or(ctx.from.line);
152    Ok(MotionResult {
153        curswant: None,
154        target: Position::new(target_row, 0),
155        linewise: false,
156        exclusive: None,
157        notice: None,
158    })
159}
160
161fn handle_next_file_boundary(
162    ctx: &MotionContext,
163    mb: &MultibufferRegistryHandle,
164) -> GrammarResult<MotionResult> {
165    let excerpts = excerpts_for_buffer(mb, ctx)?;
166    let count = ctx.count.get().max(1);
167    let target_row =
168        next_file_boundary_row(&excerpts, ctx.from.line, count).unwrap_or(ctx.from.line);
169    Ok(MotionResult {
170        curswant: None,
171        target: Position::new(target_row, 0),
172        linewise: false,
173        exclusive: None,
174        notice: None,
175    })
176}
177
178fn handle_prev_file_boundary(
179    ctx: &MotionContext,
180    mb: &MultibufferRegistryHandle,
181) -> GrammarResult<MotionResult> {
182    let excerpts = excerpts_for_buffer(mb, ctx)?;
183    let count = ctx.count.get().max(1);
184    let target_row =
185        prev_file_boundary_row(&excerpts, ctx.from.line, count).unwrap_or(ctx.from.line);
186    Ok(MotionResult {
187        curswant: None,
188        target: Position::new(target_row, 0),
189        linewise: false,
190        exclusive: None,
191        notice: None,
192    })
193}
194
195fn excerpts_for_buffer(
196    mb: &MultibufferRegistryHandle,
197    ctx: &MotionContext,
198) -> Result<Vec<Excerpt>, CommandError> {
199    ctx.cancel.check()?;
200    Ok(mb
201        .handle(ctx.buffer_id)
202        .map(|h| h.excerpts())
203        .unwrap_or_default())
204}
205
206// ──────────────────────────────────────────────────────────────
207// Pure helpers (unit-testable without grammar plumbing).
208// ──────────────────────────────────────────────────────────────
209
210/// Composed-row position of each excerpt's first row, in display
211/// order. Equivalent to the prefix sum of `excerpt.line_count()`.
212pub fn excerpt_start_rows(excerpts: &[Excerpt]) -> Vec<u32> {
213    let mut starts = Vec::with_capacity(excerpts.len());
214    let mut cursor: u32 = 0;
215    for e in excerpts {
216        starts.push(cursor);
217        cursor = cursor.saturating_add(e.line_count());
218    }
219    starts
220}
221
222/// Index of the excerpt whose composed-row range contains
223/// `cursor_row`. `None` when the cursor sits above the first
224/// excerpt (only possible on empty views or row 0 with no
225/// excerpts).
226pub fn containing_excerpt_index(excerpts: &[Excerpt], cursor_row: u32) -> Option<usize> {
227    if excerpts.is_empty() {
228        return None;
229    }
230    let starts = excerpt_start_rows(excerpts);
231    let mut found: Option<usize> = None;
232    for (i, &start) in starts.iter().enumerate() {
233        if start <= cursor_row {
234            found = Some(i);
235        } else {
236            break;
237        }
238    }
239    found
240}
241
242/// Composed row of the `count`-th excerpt strictly after the one
243/// containing `cursor_row`. `None` when there is no such excerpt.
244pub fn next_excerpt_start_row(excerpts: &[Excerpt], cursor_row: u32, count: u32) -> Option<u32> {
245    if excerpts.is_empty() || count == 0 {
246        return None;
247    }
248    let starts = excerpt_start_rows(excerpts);
249    let current = containing_excerpt_index(excerpts, cursor_row).unwrap_or(0);
250    let target_idx = current.checked_add(count as usize)?;
251    starts.get(target_idx).copied()
252}
253
254/// Composed row of the `count`-th excerpt strictly before the
255/// one containing `cursor_row`. `None` when no such excerpt.
256pub fn prev_excerpt_start_row(excerpts: &[Excerpt], cursor_row: u32, count: u32) -> Option<u32> {
257    if excerpts.is_empty() || count == 0 {
258        return None;
259    }
260    let starts = excerpt_start_rows(excerpts);
261    let current = containing_excerpt_index(excerpts, cursor_row)?;
262    let target_idx = current.checked_sub(count as usize)?;
263    starts.get(target_idx).copied()
264}
265
266/// Indices of excerpts that begin a new file group: an excerpt
267/// is a file boundary if its `source` differs from the previous
268/// excerpt's, or it is the first excerpt.
269fn file_boundary_indices(excerpts: &[Excerpt]) -> Vec<usize> {
270    let mut bounds = Vec::new();
271    let mut prev_source: Option<BufferId> = None;
272    for (i, e) in excerpts.iter().enumerate() {
273        if prev_source != Some(e.source) {
274            bounds.push(i);
275            prev_source = Some(e.source);
276        }
277    }
278    bounds
279}
280
281/// Composed row of the `count`-th file-boundary excerpt forward
282/// of the boundary containing the current excerpt. A "file
283/// boundary" is the FIRST excerpt of each file group (per
284/// `]E` semantics: jump to the start of the next file in the
285/// view).
286pub fn next_file_boundary_row(excerpts: &[Excerpt], cursor_row: u32, count: u32) -> Option<u32> {
287    if excerpts.is_empty() || count == 0 {
288        return None;
289    }
290    let starts = excerpt_start_rows(excerpts);
291    let bounds = file_boundary_indices(excerpts);
292    let current = containing_excerpt_index(excerpts, cursor_row).unwrap_or(0);
293    // Largest boundary index ≤ current.
294    let cur_pos = bounds.iter().rposition(|&b| b <= current)?;
295    let target_pos = cur_pos.checked_add(count as usize)?;
296    let target_idx = *bounds.get(target_pos)?;
297    starts.get(target_idx).copied()
298}
299
300/// Composed row of the `count`-th file-boundary excerpt backward
301/// of the boundary containing the current excerpt. A "file
302/// boundary" is the FIRST excerpt of each file group (per
303/// `[E` semantics: jump to the start of the previous file).
304pub fn prev_file_boundary_row(excerpts: &[Excerpt], cursor_row: u32, count: u32) -> Option<u32> {
305    if excerpts.is_empty() || count == 0 {
306        return None;
307    }
308    let starts = excerpt_start_rows(excerpts);
309    let bounds = file_boundary_indices(excerpts);
310    let current = containing_excerpt_index(excerpts, cursor_row)?;
311    let cur_pos = bounds.iter().rposition(|&b| b <= current)?;
312    let target_pos = cur_pos.checked_sub(count as usize)?;
313    let target_idx = bounds[target_pos];
314    starts.get(target_idx).copied()
315}
316
317#[cfg(test)]
318mod tests {
319    #![allow(clippy::unwrap_used)]
320    use super::*;
321    use lattice_core::BufferId;
322
323    use crate::{Excerpt, ExcerptHeader};
324
325    fn ex(source: BufferId, start: u32, end: u32) -> Excerpt {
326        Excerpt::new(source, start, end).with_header(ExcerptHeader::default())
327    }
328
329    #[test]
330    fn start_rows_are_prefix_sums_of_line_counts() {
331        let s = BufferId::next();
332        let excerpts = vec![ex(s, 0, 2), ex(s, 5, 7), ex(s, 10, 10)];
333        // line_count: 3, 3, 1
334        assert_eq!(excerpt_start_rows(&excerpts), vec![0, 3, 6]);
335    }
336
337    #[test]
338    fn containing_excerpt_walks_the_starts() {
339        let s = BufferId::next();
340        let excerpts = vec![ex(s, 0, 2), ex(s, 0, 2), ex(s, 0, 1)];
341        // starts: [0, 3, 6]
342        assert_eq!(containing_excerpt_index(&excerpts, 0), Some(0));
343        assert_eq!(containing_excerpt_index(&excerpts, 2), Some(0));
344        assert_eq!(containing_excerpt_index(&excerpts, 3), Some(1));
345        assert_eq!(containing_excerpt_index(&excerpts, 5), Some(1));
346        assert_eq!(containing_excerpt_index(&excerpts, 6), Some(2));
347        assert_eq!(containing_excerpt_index(&excerpts, 100), Some(2));
348    }
349
350    #[test]
351    fn next_excerpt_start_advances_count_excerpts() {
352        let s = BufferId::next();
353        let excerpts = vec![ex(s, 0, 1), ex(s, 0, 0), ex(s, 0, 2)];
354        // starts: [0, 2, 3]
355        assert_eq!(next_excerpt_start_row(&excerpts, 0, 1), Some(2));
356        assert_eq!(next_excerpt_start_row(&excerpts, 0, 2), Some(3));
357        assert_eq!(next_excerpt_start_row(&excerpts, 0, 3), None);
358        assert_eq!(next_excerpt_start_row(&excerpts, 2, 1), Some(3));
359        // From the last excerpt, no next.
360        assert_eq!(next_excerpt_start_row(&excerpts, 3, 1), None);
361    }
362
363    #[test]
364    fn prev_excerpt_start_backs_off_count_excerpts() {
365        let s = BufferId::next();
366        let excerpts = vec![ex(s, 0, 1), ex(s, 0, 0), ex(s, 0, 2)];
367        // starts: [0, 2, 3]
368        assert_eq!(prev_excerpt_start_row(&excerpts, 3, 1), Some(2));
369        assert_eq!(prev_excerpt_start_row(&excerpts, 3, 2), Some(0));
370        assert_eq!(prev_excerpt_start_row(&excerpts, 3, 3), None);
371        assert_eq!(prev_excerpt_start_row(&excerpts, 2, 1), Some(0));
372        // From the first excerpt, no prev.
373        assert_eq!(prev_excerpt_start_row(&excerpts, 0, 1), None);
374    }
375
376    #[test]
377    fn next_file_boundary_skips_excerpts_with_same_source() {
378        let a = BufferId::next();
379        let b = BufferId::next();
380        let c = BufferId::next();
381        let excerpts = vec![
382            ex(a, 0, 1), // composed 0
383            ex(a, 0, 0), // composed 2 — same source, skip
384            ex(b, 0, 2), // composed 3 — first boundary
385            ex(b, 0, 0), // composed 6 — same source as prior
386            ex(c, 0, 1), // composed 7 — second boundary
387        ];
388        assert_eq!(next_file_boundary_row(&excerpts, 0, 1), Some(3));
389        assert_eq!(next_file_boundary_row(&excerpts, 0, 2), Some(7));
390        assert_eq!(next_file_boundary_row(&excerpts, 0, 3), None);
391        // From within the b-source excerpt — next boundary is c.
392        assert_eq!(next_file_boundary_row(&excerpts, 3, 1), Some(7));
393        // From within the last excerpt — no next.
394        assert_eq!(next_file_boundary_row(&excerpts, 7, 1), None);
395    }
396
397    #[test]
398    fn prev_file_boundary_skips_excerpts_with_same_source() {
399        let a = BufferId::next();
400        let b = BufferId::next();
401        let c = BufferId::next();
402        let excerpts = vec![
403            ex(a, 0, 1), // composed 0
404            ex(b, 0, 2), // composed 2 — first b
405            ex(b, 0, 0), // composed 5 — same as prior
406            ex(c, 0, 1), // composed 6 — first c
407        ];
408        // From c, prev boundary is b at composed 2.
409        assert_eq!(prev_file_boundary_row(&excerpts, 6, 1), Some(2));
410        assert_eq!(prev_file_boundary_row(&excerpts, 6, 2), Some(0));
411        assert_eq!(prev_file_boundary_row(&excerpts, 6, 3), None);
412    }
413
414    #[test]
415    fn empty_excerpts_returns_none() {
416        let excerpts: Vec<Excerpt> = Vec::new();
417        assert_eq!(next_excerpt_start_row(&excerpts, 0, 1), None);
418        assert_eq!(prev_excerpt_start_row(&excerpts, 0, 1), None);
419        assert_eq!(next_file_boundary_row(&excerpts, 0, 1), None);
420        assert_eq!(prev_file_boundary_row(&excerpts, 0, 1), None);
421    }
422
423    #[test]
424    fn zero_count_returns_none() {
425        let s = BufferId::next();
426        let excerpts = vec![ex(s, 0, 1)];
427        assert_eq!(next_excerpt_start_row(&excerpts, 0, 0), None);
428        assert_eq!(prev_excerpt_start_row(&excerpts, 0, 0), None);
429        assert_eq!(next_file_boundary_row(&excerpts, 0, 0), None);
430        assert_eq!(prev_file_boundary_row(&excerpts, 0, 0), None);
431    }
432}