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