Skip to main content

lattice_host/
pane_group.rs

1//! D.4.a (2026-05-29): pane-group substrate.
2//!
3//! See `docs/dev/architecture/pane-groups.md` for the full
4//! design; capsule:
5//!
6//! - A [`PaneGroup`] is a set of `(pane, buffer)` pairs that
7//!   scroll together under a pluggable [`RowMapper`].
8//! - Membership is keyed on the pair, not the pane alone,
9//!   so the binding suspends naturally when the user
10//!   switches a pane's buffer away from its registered
11//!   counterpart and resumes when they switch back.
12//! - Propagation runs at the dispatch tail
13//!   ([`crate::Editor::publish_render_state`]); active pane
14//!   drives, others follow.
15//!
16//! This slice ships the trait + registry; consumers
17//! (`HunkRowMapper` in D.4.b, filler-row provider in
18//! D.4.c, `:diffsplit` wiring in D.4.d) land in later
19//! slices.
20
21use std::sync::Arc;
22
23use lattice_core::BufferId;
24use lattice_core::ui::pane::{PaneGroupId, PaneId};
25
26/// D.4.a: the `(pane, buffer)` pair a membership is keyed
27/// on. Propagation observes a pane's *currently-displayed*
28/// buffer at dispatch tail and skips the member when it no
29/// longer matches `buffer`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct PaneGroupMember {
32    pub pane: PaneId,
33    pub buffer: BufferId,
34}
35
36/// D.4.a: pluggable row-mapping function for a pane group.
37///
38/// DX.3 (BC.6 diff extraction): the trait moved DOWN to
39/// [`lattice_core::ui::pane::RowMapper`] so `lattice-diff`'s
40/// `HunkRowMapper` can impl it without the host. Re-exported here so
41/// every `crate::pane_group::RowMapper` call site (the `PaneGroup`
42/// registry below, `HunkRowMapper`, the dispatch propagation path) is
43/// unchanged; the host keeps the `PaneGroup` registry + the
44/// `Identity`/`Offset` impls.
45pub use lattice_core::ui::pane::RowMapper;
46
47/// D.4.a: default mapper for `:set scrollbind` parity.
48/// Maps every row to itself.
49#[derive(Debug, Default)]
50pub struct IdentityRowMapper;
51
52impl RowMapper for IdentityRowMapper {
53    fn map_row(&self, _from: usize, _to: usize, row: u32) -> u32 {
54        row
55    }
56}
57
58/// D.4.a: one scroll-binding group.
59///
60/// `members` carries `(pane, buffer)` pairs; `mapper`
61/// translates rows. Owned by `Editor::pane_groups`;
62/// subsystems mint via [`crate::dispatch::Editor::add_pane_group`]
63/// and drop via [`crate::dispatch::Editor::drop_pane_group`].
64pub struct PaneGroup {
65    pub id: PaneGroupId,
66    pub members: Vec<PaneGroupMember>,
67    pub mapper: Arc<dyn RowMapper>,
68}
69
70impl std::fmt::Debug for PaneGroup {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("PaneGroup")
73            .field("id", &self.id)
74            .field("members", &self.members)
75            .field("mapper", &"<dyn RowMapper>")
76            .finish()
77    }
78}
79
80impl PaneGroup {
81    /// Construct a new group with a freshly-allocated id.
82    /// Callers populate `members` + supply a mapper.
83    pub fn new(members: Vec<PaneGroupMember>, mapper: Arc<dyn RowMapper>) -> Self {
84        Self {
85            id: PaneGroupId::next(),
86            members,
87            mapper,
88        }
89    }
90
91    /// Find a member's index by its `(pane, buffer)` pair.
92    /// Returns `None` when no membership matches the pair.
93    pub fn index_of(&self, member: PaneGroupMember) -> Option<usize> {
94        self.members.iter().position(|m| *m == member)
95    }
96}
97
98/// D.4.a: an offset-row stub mapper used by the unit tests
99/// to verify that the mapping path is actually plumbed
100/// (identity alone wouldn't distinguish "mapper was called"
101/// from "mapper was bypassed"). Lives in non-test code so
102/// downstream slices can reuse it as a smoke-test mapper.
103#[derive(Debug)]
104pub struct OffsetRowMapper {
105    pub offset: i32,
106}
107
108impl RowMapper for OffsetRowMapper {
109    fn map_row(&self, _from: usize, _to: usize, row: u32) -> u32 {
110        if self.offset >= 0 {
111            row.saturating_add(self.offset as u32)
112        } else {
113            row.saturating_sub(self.offset.unsigned_abs())
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    #![allow(clippy::unwrap_used)]
121    use super::*;
122
123    fn mk_member(pane: u32, buffer: u32) -> PaneGroupMember {
124        PaneGroupMember {
125            pane: PaneId(pane),
126            buffer: BufferId(buffer),
127        }
128    }
129
130    #[test]
131    fn identity_mapper_returns_input_row() {
132        let m = IdentityRowMapper;
133        assert_eq!(m.map_row(0, 1, 42), 42);
134        assert_eq!(m.map_row(3, 0, 0), 0);
135    }
136
137    #[test]
138    fn offset_mapper_shifts_positive_and_negative() {
139        let pos = OffsetRowMapper { offset: 5 };
140        assert_eq!(pos.map_row(0, 1, 10), 15);
141        let neg = OffsetRowMapper { offset: -3 };
142        assert_eq!(neg.map_row(0, 1, 10), 7);
143        // saturating: negative offset past zero
144        assert_eq!(neg.map_row(0, 1, 1), 0);
145    }
146
147    #[test]
148    fn group_id_is_unique() {
149        let a = PaneGroup::new(vec![], Arc::new(IdentityRowMapper));
150        let b = PaneGroup::new(vec![], Arc::new(IdentityRowMapper));
151        assert_ne!(a.id, b.id);
152    }
153
154    #[test]
155    fn index_of_finds_membership_by_pair() {
156        let group = PaneGroup::new(
157            vec![mk_member(1, 100), mk_member(2, 200)],
158            Arc::new(IdentityRowMapper),
159        );
160        assert_eq!(group.index_of(mk_member(1, 100)), Some(0));
161        assert_eq!(group.index_of(mk_member(2, 200)), Some(1));
162        // Pane matches but buffer doesn't — not a member.
163        assert_eq!(group.index_of(mk_member(1, 999)), None);
164        // Buffer matches but pane doesn't — not a member.
165        assert_eq!(group.index_of(mk_member(9, 100)), None);
166    }
167}