Skip to main content

fresh/view/
scroll_sync.rs

1/// Scroll synchronization for side-by-side diff views
2///
3/// This module implements marker-based sync anchors for synchronized scrolling
4/// between two panes showing different versions of a file (e.g., old vs new in a diff).
5///
6/// Key design principles:
7/// - Single source of truth: `scroll_line` is the authoritative position
8/// - Sync anchors mark corresponding lines between buffers (e.g., hunk boundaries)
9/// - Synchronization happens at render time, not via async commands
10/// - No feedback loops because only one position is tracked
11use crate::model::event::SplitId;
12use serde::{Deserialize, Serialize};
13
14/// A sync anchor linking corresponding line positions in two buffers
15///
16/// Anchors are placed at diff hunk boundaries where both buffers
17/// have a known correspondence (e.g., start of context, end of hunk).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SyncAnchor {
20    /// Line number in the left (primary) buffer
21    pub left_line: usize,
22    /// Line number in the right (secondary) buffer
23    pub right_line: usize,
24}
25
26/// A unique identifier for a scroll sync group
27pub type ScrollSyncGroupId = u32;
28
29/// A group of two splits that scroll together with anchor-based synchronization
30///
31/// Unlike the simple sync_group which applies the same scroll delta to all splits,
32/// this uses sync anchors to correctly handle buffers with different line counts.
33#[derive(Debug, Clone)]
34pub struct ScrollSyncGroup {
35    /// Unique ID for this sync group
36    pub id: ScrollSyncGroupId,
37    /// The left (primary) split - scroll position is tracked in this split's line space
38    pub left_split: SplitId,
39    /// The right (secondary) split - position is derived from anchors
40    pub right_split: SplitId,
41    /// Single source of truth: scroll position in left buffer's line space
42    /// Both splits derive their viewport position from this value
43    pub scroll_line: usize,
44    /// Sync anchors ordered by left_line
45    /// These mark corresponding positions between the two buffers
46    pub anchors: Vec<SyncAnchor>,
47}
48
49impl ScrollSyncGroup {
50    /// Create a new scroll sync group
51    pub fn new(id: ScrollSyncGroupId, left_split: SplitId, right_split: SplitId) -> Self {
52        Self {
53            id,
54            left_split,
55            right_split,
56            scroll_line: 0,
57            anchors: vec![SyncAnchor {
58                left_line: 0,
59                right_line: 0,
60            }],
61        }
62    }
63
64    /// Set the sync anchors (replacing any existing ones)
65    /// Anchors should be sorted by left_line
66    pub fn set_anchors(&mut self, anchors: Vec<SyncAnchor>) {
67        self.anchors = anchors;
68        // Ensure there's always at least the origin anchor
69        if self.anchors.is_empty() {
70            self.anchors.push(SyncAnchor {
71                left_line: 0,
72                right_line: 0,
73            });
74        }
75    }
76
77    /// Check if a split is part of this sync group
78    pub fn contains_split(&self, split_id: SplitId) -> bool {
79        self.left_split == split_id || self.right_split == split_id
80    }
81
82    /// Check if a split is the left (primary) split
83    pub fn is_left_split(&self, split_id: SplitId) -> bool {
84        self.left_split == split_id
85    }
86
87    /// Convert a line number from left buffer space to right buffer space
88    pub fn left_to_right_line(&self, left_line: usize) -> usize {
89        // Find the anchor just at or before left_line
90        let anchor = self
91            .anchors
92            .iter()
93            .rfind(|a| a.left_line <= left_line)
94            .unwrap_or(&self.anchors[0]);
95
96        // Calculate offset from anchor
97        let offset = left_line.saturating_sub(anchor.left_line);
98
99        // Apply offset to right side
100        anchor.right_line.saturating_add(offset)
101    }
102
103    /// Convert a line number from right buffer space to left buffer space
104    pub fn right_to_left_line(&self, right_line: usize) -> usize {
105        // Find the anchor just at or before right_line in right buffer space
106        let anchor = self
107            .anchors
108            .iter()
109            .rfind(|a| a.right_line <= right_line)
110            .unwrap_or(&self.anchors[0]);
111
112        // Calculate offset from anchor in right space
113        let offset = right_line.saturating_sub(anchor.right_line);
114
115        // Apply offset to left side
116        anchor.left_line.saturating_add(offset)
117    }
118
119    /// Update scroll position from a scroll delta on the given split
120    /// Returns true if this group handled the scroll
121    pub fn apply_scroll_delta(&mut self, split_id: SplitId, delta_lines: isize) -> bool {
122        if !self.contains_split(split_id) {
123            return false;
124        }
125
126        // Apply delta to scroll_line (which is always in left buffer space)
127        let new_scroll = if delta_lines >= 0 {
128            self.scroll_line.saturating_add(delta_lines as usize)
129        } else {
130            self.scroll_line.saturating_sub(delta_lines.unsigned_abs())
131        };
132
133        self.scroll_line = new_scroll;
134        true
135    }
136
137    /// Set scroll position directly (used for SetViewport events)
138    /// The line number should be in left buffer space
139    pub fn set_scroll_line(&mut self, line: usize) {
140        self.scroll_line = line;
141    }
142
143    /// Get the scroll line for the left split
144    pub fn left_scroll_line(&self) -> usize {
145        self.scroll_line
146    }
147
148    /// Get the scroll line for the right split (derived via anchors)
149    pub fn right_scroll_line(&self) -> usize {
150        self.left_to_right_line(self.scroll_line)
151    }
152
153    /// Get the scroll line for a specific split
154    pub fn scroll_line_for_split(&self, split_id: SplitId) -> usize {
155        if split_id == self.left_split {
156            self.left_scroll_line()
157        } else {
158            self.right_scroll_line()
159        }
160    }
161}
162
163/// Manager for scroll sync groups
164#[derive(Debug, Default)]
165pub struct ScrollSyncManager {
166    /// Active scroll sync groups
167    groups: Vec<ScrollSyncGroup>,
168    /// Next group ID to assign
169    next_id: ScrollSyncGroupId,
170}
171
172impl ScrollSyncManager {
173    /// Create a new scroll sync manager
174    pub fn new() -> Self {
175        Self {
176            groups: Vec::new(),
177            next_id: 1,
178        }
179    }
180
181    /// Create a new scroll sync group and return its ID
182    pub fn create_group(&mut self, left_split: SplitId, right_split: SplitId) -> ScrollSyncGroupId {
183        let id = self.next_id;
184        self.next_id += 1;
185
186        let group = ScrollSyncGroup::new(id, left_split, right_split);
187        self.groups.push(group);
188        id
189    }
190
191    /// Create a scroll sync group with a plugin-provided ID
192    /// Returns true if created successfully, false if ID already exists
193    pub fn create_group_with_id(
194        &mut self,
195        id: ScrollSyncGroupId,
196        left_split: SplitId,
197        right_split: SplitId,
198    ) -> bool {
199        // Check if ID already exists
200        if self.groups.iter().any(|g| g.id == id) {
201            return false;
202        }
203
204        let group = ScrollSyncGroup::new(id, left_split, right_split);
205        self.groups.push(group);
206        true
207    }
208
209    /// Remove a scroll sync group by ID
210    pub fn remove_group(&mut self, id: ScrollSyncGroupId) -> bool {
211        if let Some(pos) = self.groups.iter().position(|g| g.id == id) {
212            self.groups.remove(pos);
213            true
214        } else {
215            false
216        }
217    }
218
219    /// Remove all scroll sync groups containing a specific split
220    pub fn remove_groups_for_split(&mut self, split_id: SplitId) {
221        self.groups.retain(|g| !g.contains_split(split_id));
222    }
223
224    /// Get a mutable reference to a group by ID
225    pub fn get_group_mut(&mut self, id: ScrollSyncGroupId) -> Option<&mut ScrollSyncGroup> {
226        self.groups.iter_mut().find(|g| g.id == id)
227    }
228
229    /// Get a reference to a group by ID
230    pub fn get_group(&self, id: ScrollSyncGroupId) -> Option<&ScrollSyncGroup> {
231        self.groups.iter().find(|g| g.id == id)
232    }
233
234    /// Find the group containing a specific split
235    pub fn find_group_for_split(&self, split_id: SplitId) -> Option<&ScrollSyncGroup> {
236        self.groups.iter().find(|g| g.contains_split(split_id))
237    }
238
239    /// Find the group containing a specific split (mutable)
240    pub fn find_group_for_split_mut(&mut self, split_id: SplitId) -> Option<&mut ScrollSyncGroup> {
241        self.groups.iter_mut().find(|g| g.contains_split(split_id))
242    }
243
244    /// Check if a split is in any scroll sync group
245    pub fn is_split_synced(&self, split_id: SplitId) -> bool {
246        self.groups.iter().any(|g| g.contains_split(split_id))
247    }
248
249    /// Get all groups (for iteration during render)
250    pub fn groups(&self) -> &[ScrollSyncGroup] {
251        &self.groups
252    }
253
254    /// Apply scroll delta to the group containing the split
255    /// Returns true if a group handled the scroll
256    pub fn apply_scroll_delta(&mut self, split_id: SplitId, delta_lines: isize) -> bool {
257        if let Some(group) = self.find_group_for_split_mut(split_id) {
258            group.apply_scroll_delta(split_id, delta_lines);
259            true
260        } else {
261            false
262        }
263    }
264
265    /// Set anchors for a group
266    pub fn set_anchors(&mut self, group_id: ScrollSyncGroupId, anchors: Vec<SyncAnchor>) {
267        if let Some(group) = self.get_group_mut(group_id) {
268            group.set_anchors(anchors);
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_left_to_right_line_simple() {
279        let mut group = ScrollSyncGroup::new(1, SplitId(1), SplitId(2));
280        group.set_anchors(vec![
281            SyncAnchor {
282                left_line: 0,
283                right_line: 0,
284            },
285            SyncAnchor {
286                left_line: 10,
287                right_line: 10,
288            },
289            SyncAnchor {
290                left_line: 20,
291                right_line: 25,
292            }, // Right has 5 extra lines
293        ]);
294
295        // Before any anchors
296        assert_eq!(group.left_to_right_line(0), 0);
297        assert_eq!(group.left_to_right_line(5), 5);
298
299        // After second anchor (1:1 mapping)
300        assert_eq!(group.left_to_right_line(10), 10);
301        assert_eq!(group.left_to_right_line(15), 15);
302
303        // After third anchor (offset by 5)
304        assert_eq!(group.left_to_right_line(20), 25);
305        assert_eq!(group.left_to_right_line(25), 30);
306    }
307
308    #[test]
309    fn test_right_to_left_line() {
310        let mut group = ScrollSyncGroup::new(1, SplitId(1), SplitId(2));
311        group.set_anchors(vec![
312            SyncAnchor {
313                left_line: 0,
314                right_line: 0,
315            },
316            SyncAnchor {
317                left_line: 10,
318                right_line: 15,
319            }, // Right has 5 extra lines
320        ]);
321
322        // Before anchor
323        assert_eq!(group.right_to_left_line(0), 0);
324        assert_eq!(group.right_to_left_line(5), 5);
325
326        // After anchor
327        assert_eq!(group.right_to_left_line(15), 10);
328        assert_eq!(group.right_to_left_line(20), 15);
329    }
330
331    #[test]
332    fn test_scroll_delta() {
333        let mut group = ScrollSyncGroup::new(1, SplitId(1), SplitId(2));
334        group.set_anchors(vec![
335            SyncAnchor {
336                left_line: 0,
337                right_line: 0,
338            },
339            SyncAnchor {
340                left_line: 50,
341                right_line: 60,
342            },
343        ]);
344
345        // Initial position
346        assert_eq!(group.left_scroll_line(), 0);
347        assert_eq!(group.right_scroll_line(), 0);
348
349        // Scroll down 10 lines
350        group.apply_scroll_delta(SplitId(1), 10);
351        assert_eq!(group.left_scroll_line(), 10);
352        assert_eq!(group.right_scroll_line(), 10);
353
354        // Scroll to position past anchor
355        group.set_scroll_line(55);
356        assert_eq!(group.left_scroll_line(), 55);
357        assert_eq!(group.right_scroll_line(), 65); // 60 + 5
358    }
359}