Skip to main content

fresh/app/
scroll_sync.rs

1//! Scroll-sync orchestrators on `Editor`.
2//!
3//! - `ensure_active_tab_visible` — adjusts a split's tab-bar scroll offset
4//!   so the active tab is on screen.
5//! - `sync_scroll_groups` — when splits share a scroll group (e.g. for
6//!   side-by-side diffs), keep their viewports in lockstep.
7//! - `pre_sync_ensure_visible` — pre-sync hook that ensures the active
8//!   split's cursor is on screen so the scroll-group sync uses a valid
9//!   anchor.
10
11use crate::model::event::{BufferId, LeafId, SplitId};
12
13impl crate::app::window::Window {
14    /// Ensure the active tab in a split is visible by adjusting its
15    /// scroll offset. Pure window-state mutation: split tree +
16    /// view_states + buffer_metadata + composite_buffers + grouped_subtrees
17    /// all live on `Window`.
18    pub fn ensure_active_tab_visible(
19        &mut self,
20        split_id: LeafId,
21        active_buffer: BufferId,
22        available_width: u16,
23    ) {
24        tracing::debug!(
25            "ensure_active_tab_visible called: split={:?}, buffer={:?}, width={}",
26            split_id,
27            active_buffer,
28            available_width
29        );
30        let group_names: std::collections::HashMap<LeafId, String> = self
31            .grouped_subtrees
32            .iter()
33            .filter_map(|(leaf_id, node)| {
34                if let crate::view::split::SplitNode::Grouped { name, .. } = node {
35                    Some((*leaf_id, name.clone()))
36                } else {
37                    None
38                }
39            })
40            .collect();
41        let metadata = &self.buffer_metadata;
42        let composites = &self.composite_buffers;
43        let preview_buffer = self.preview.map(|(_, b)| b);
44
45        self.buffers.with_all_mut(|buffer_map, _mgr, vs_map| {
46            let Some(view_state) = vs_map.get_mut(&split_id) else {
47                return;
48            };
49            let split_buffers = view_state.open_buffers.clone();
50            let (tab_widths, rendered_targets) = crate::view::ui::tabs::calculate_tab_widths(
51                &split_buffers,
52                buffer_map,
53                metadata,
54                composites,
55                &group_names,
56                preview_buffer,
57            );
58
59            let total_tabs_width: usize = tab_widths.iter().sum();
60            // Reserve the pinned "+" button's column when the tabs overflow, so
61            // the active tab stays fully visible and never slips under it.
62            let max_visible_width = crate::view::ui::tabs::tabs_render_width(
63                total_tabs_width,
64                available_width as usize,
65            );
66
67            let active_target = view_state.active_target();
68            let active_target = if matches!(active_target, crate::view::split::TabTarget::Buffer(_))
69            {
70                crate::view::split::TabTarget::Buffer(active_buffer)
71            } else {
72                active_target
73            };
74
75            let active_tab_index = rendered_targets.iter().position(|t| *t == active_target);
76            let active_width_index =
77                active_tab_index.map(|buf_idx| if buf_idx == 0 { 0 } else { buf_idx * 2 });
78
79            let old_offset = view_state.tab_scroll_offset;
80            let new_scroll_offset = if let Some(idx) = active_width_index {
81                crate::view::ui::tabs::scroll_to_show_tab(
82                    &tab_widths,
83                    idx,
84                    view_state.tab_scroll_offset,
85                    max_visible_width,
86                )
87            } else {
88                view_state
89                    .tab_scroll_offset
90                    .min(total_tabs_width.saturating_sub(max_visible_width))
91            };
92
93            tracing::debug!(
94                "  -> offset: {} -> {} (idx={:?}, max_width={}, total={})",
95                old_offset,
96                new_scroll_offset,
97                active_width_index,
98                max_visible_width,
99                total_tabs_width
100            );
101            view_state.tab_scroll_offset = new_scroll_offset;
102        });
103    }
104
105    /// Synchronize viewports for all scroll-sync groups in this window.
106    ///
107    /// For each registered group containing the active split, derive the
108    /// active split's top line from its viewport and project it onto the
109    /// paired split via the group's mapping. Then, when same-buffer
110    /// scroll sync is enabled, also mirror the active split's `top_byte`
111    /// onto every other split that shows the same buffer (and isn't in
112    /// an explicit sync group). The bottom-edge case sets
113    /// `sync_scroll_to_end` so the render pass does the
114    /// soft-break-aware fix-up using view lines.
115    pub(super) fn sync_scroll_groups(&mut self) {
116        let (mgr, vs_map) = self
117            .buffers
118            .splits()
119            .expect("window must have a populated split layout");
120        let active_split = mgr.active_split();
121        let group_count = self.scroll_sync_manager.groups().len();
122
123        if group_count > 0 {
124            tracing::debug!(
125                "sync_scroll_groups: active_split={:?}, {} groups",
126                active_split,
127                group_count
128            );
129        }
130
131        let sync_info: Vec<_> = self
132            .scroll_sync_manager
133            .groups()
134            .iter()
135            .filter_map(|group| {
136                tracing::debug!(
137                    "sync_scroll_groups: checking group {}, left={:?}, right={:?}",
138                    group.id,
139                    group.left_split,
140                    group.right_split
141                );
142
143                if !group.contains_split(active_split.into()) {
144                    tracing::debug!(
145                        "sync_scroll_groups: active split {:?} not in group",
146                        active_split
147                    );
148                    return None;
149                }
150
151                let active_top_byte = vs_map.get(&active_split)?.viewport.top_byte;
152                let active_buffer_id = mgr.buffer_for_split(active_split)?;
153                let buffer_state = self.buffers.get(&active_buffer_id)?;
154                let buffer_len = buffer_state.buffer.len();
155                let active_line = buffer_state.buffer.get_line_number(active_top_byte);
156
157                tracing::debug!(
158                    "sync_scroll_groups: active_split={:?}, buffer_id={:?}, top_byte={}, buffer_len={}, active_line={}",
159                    active_split,
160                    active_buffer_id,
161                    active_top_byte,
162                    buffer_len,
163                    active_line
164                );
165
166                let (other_split, other_line) = if group.is_left_split(active_split.into()) {
167                    (group.right_split, group.left_to_right_line(active_line))
168                } else {
169                    (group.left_split, group.right_to_left_line(active_line))
170                };
171
172                tracing::debug!(
173                    "sync_scroll_groups: syncing other_split={:?} to line {}",
174                    other_split,
175                    other_line
176                );
177
178                Some((other_split, other_line))
179            })
180            .collect();
181
182        for (other_split, target_line) in sync_info {
183            let other_leaf = LeafId(other_split);
184            let buffer_id = self
185                .buffers
186                .splits()
187                .expect("window must have a populated split layout")
188                .0
189                .buffer_for_split(other_leaf);
190            if let Some(buffer_id) = buffer_id {
191                self.scroll_split_viewport_to(buffer_id, other_leaf, target_line, false);
192            }
193        }
194
195        let active_buffer_id = if self.same_buffer_scroll_sync {
196            self.buffers
197                .splits()
198                .expect("window must have a populated split layout")
199                .0
200                .buffer_for_split(active_split)
201        } else {
202            None
203        };
204        if let Some(active_buf_id) = active_buffer_id {
205            let (mgr, vs_map) = self
206                .buffers
207                .splits()
208                .expect("window must have a populated split layout");
209            let active_top_byte = vs_map.get(&active_split).map(|vs| vs.viewport.top_byte);
210            let active_viewport_height = vs_map
211                .get(&active_split)
212                .map(|vs| vs.viewport.visible_line_count())
213                .unwrap_or(0);
214
215            if let Some(top_byte) = active_top_byte {
216                let other_splits: Vec<_> = vs_map
217                    .keys()
218                    .filter(|&&s| {
219                        s != active_split
220                            && mgr.buffer_for_split(s) == Some(active_buf_id)
221                            && !self.scroll_sync_manager.is_split_synced(s.into())
222                    })
223                    .copied()
224                    .collect();
225
226                if !other_splits.is_empty() {
227                    let at_bottom = if let Some(state) = self.buffers.get_mut(&active_buf_id) {
228                        let mut iter = state.buffer.line_iterator(top_byte, 80);
229                        let mut lines_remaining = 0;
230                        while iter.next_line().is_some() {
231                            lines_remaining += 1;
232                            if lines_remaining > active_viewport_height {
233                                break;
234                            }
235                        }
236                        lines_remaining <= active_viewport_height
237                    } else {
238                        false
239                    };
240
241                    let (_, vs_map_mut) = self
242                        .buffers
243                        .splits_mut()
244                        .expect("window must have a populated split layout");
245                    for other_split in other_splits {
246                        if let Some(view_state) = vs_map_mut.get_mut(&other_split) {
247                            view_state.viewport.top_byte = top_byte;
248                            view_state.viewport.sync_scroll_to_end = at_bottom;
249                        }
250                    }
251                }
252            }
253        }
254    }
255
256    /// Pre-sync ensure-visible hook for scroll-sync groups in this window.
257    ///
258    /// When the active split is in a sync group we update its viewport
259    /// here (before `sync_scroll_groups`) so commands like `G` produce
260    /// the right scroll position that gets mirrored. The other split in
261    /// the group is then marked to skip `ensure_visible` during render
262    /// so the sync isn't undone. Same-buffer sync mirrors the same
263    /// "skip" mark across the other splits showing the same buffer.
264    pub(super) fn pre_sync_ensure_visible(&mut self, active_split: LeafId) {
265        let group_info = self
266            .scroll_sync_manager
267            .find_group_for_split(active_split.into())
268            .map(|g| (g.left_split, g.right_split));
269
270        if let Some((left_split, right_split)) = group_info {
271            let buffer_id = self
272                .buffers
273                .splits()
274                .expect("window must have a populated split layout")
275                .0
276                .buffer_for_split(active_split);
277            if let Some(buffer_id) = buffer_id {
278                self.ensure_cursor_visible_for_split(buffer_id, active_split);
279            }
280
281            let active_sid: SplitId = active_split.into();
282            let other_split: SplitId = if active_sid == left_split {
283                right_split
284            } else {
285                left_split
286            };
287
288            if let Some((_, vs_map)) = self.buffers.splits_mut() {
289                if let Some(view_state) = vs_map.get_mut(&LeafId(other_split)) {
290                    view_state.viewport.set_skip_ensure_visible();
291                    tracing::debug!(
292                        "pre_sync_ensure_visible: marked other split {:?} to skip ensure_visible",
293                        other_split
294                    );
295                }
296            }
297        }
298
299        if !self.same_buffer_scroll_sync {
300            return;
301        }
302        let active_buf_id = match self
303            .buffers
304            .splits()
305            .expect("window must have a populated split layout")
306            .0
307            .buffer_for_split(active_split)
308        {
309            Some(b) => b,
310            None => return,
311        };
312
313        let other_same_buffer_splits: Vec<_> = {
314            let (mgr, vs_map) = self
315                .buffers
316                .splits()
317                .expect("window must have a populated split layout");
318            vs_map
319                .keys()
320                .filter(|&&s| {
321                    s != active_split
322                        && mgr.buffer_for_split(s) == Some(active_buf_id)
323                        && !self.scroll_sync_manager.is_split_synced(s.into())
324                })
325                .copied()
326                .collect()
327        };
328
329        if let Some((_, vs_map)) = self.buffers.splits_mut() {
330            for other_split in other_same_buffer_splits {
331                if let Some(view_state) = vs_map.get_mut(&other_split) {
332                    view_state.viewport.set_skip_ensure_visible();
333                }
334            }
335        }
336    }
337}