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}