1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
//! Scroll-sync orchestrators on `Editor`.
//!
//! - `ensure_active_tab_visible` — adjusts a split's tab-bar scroll offset
//! so the active tab is on screen.
//! - `sync_scroll_groups` — when splits share a scroll group (e.g. for
//! side-by-side diffs), keep their viewports in lockstep.
//! - `pre_sync_ensure_visible` — pre-sync hook that ensures the active
//! split's cursor is on screen so the scroll-group sync uses a valid
//! anchor.
use crate::model::event::{BufferId, LeafId, SplitId};
use crate::view::folding::CollapsedFoldLineRange;
use crate::view::split::SplitViewState;
use super::Editor;
impl Editor {
/// Ensure the active tab in a split is visible by adjusting its scroll offset.
/// This function recalculates the required scroll_offset based on the active tab's position
/// and the available width, and updates the SplitViewState.
pub(super) fn ensure_active_tab_visible(
&mut self,
split_id: LeafId,
active_buffer: BufferId,
available_width: u16,
) {
tracing::debug!(
"ensure_active_tab_visible called: split={:?}, buffer={:?}, width={}",
split_id,
active_buffer,
available_width
);
let Some(view_state) = self.split_view_states.get_mut(&split_id) else {
tracing::debug!(" -> no view_state for split");
return;
};
let split_buffers = view_state.open_buffers.clone();
// Collect group names from the stashed Grouped subtrees.
let group_names: std::collections::HashMap<LeafId, String> = self
.grouped_subtrees
.iter()
.filter_map(|(leaf_id, node)| {
if let crate::view::split::SplitNode::Grouped { name, .. } = node {
Some((*leaf_id, name.clone()))
} else {
None
}
})
.collect();
// Use the shared function to calculate tab widths (same as render_for_split)
let (tab_widths, rendered_targets) = crate::view::ui::tabs::calculate_tab_widths(
&split_buffers,
&self.buffers,
&self.buffer_metadata,
&self.composite_buffers,
&group_names,
);
let total_tabs_width: usize = tab_widths.iter().sum();
let max_visible_width = available_width as usize;
// Determine the active target from the SplitViewState marker.
let active_target = view_state.active_target();
// If the caller passed an explicit buffer_id and the split doesn't
// have a group marked active, use that buffer as the target.
let active_target = if matches!(active_target, crate::view::split::TabTarget::Buffer(_)) {
crate::view::split::TabTarget::Buffer(active_buffer)
} else {
active_target
};
// Find the active tab index among rendered targets
// Note: tab_widths includes separators, so we need to map tab index to width index
let active_tab_index = rendered_targets.iter().position(|t| *t == active_target);
// Map buffer index to width index (accounting for separators)
// Widths are: [sep?, tab0, sep, tab1, sep, tab2, ...]
// First tab has no separator before it, subsequent tabs have separator before
let active_width_index = active_tab_index.map(|buf_idx| {
if buf_idx == 0 {
0
} else {
// Each tab after the first has a separator before it
// So tab N is at position 2*N (sep before tab1 is at 1, tab1 at 2, sep before tab2 at 3, tab2 at 4, etc.)
// Wait, the structure is: [tab0, sep, tab1, sep, tab2]
// So tab N (0-indexed) is at position 2*N
buf_idx * 2
}
});
// Calculate offset to bring active tab into view
let old_offset = view_state.tab_scroll_offset;
let new_scroll_offset = if let Some(idx) = active_width_index {
crate::view::ui::tabs::scroll_to_show_tab(
&tab_widths,
idx,
view_state.tab_scroll_offset,
max_visible_width,
)
} else {
view_state
.tab_scroll_offset
.min(total_tabs_width.saturating_sub(max_visible_width))
};
tracing::debug!(
" -> offset: {} -> {} (idx={:?}, max_width={}, total={})",
old_offset,
new_scroll_offset,
active_width_index,
max_visible_width,
total_tabs_width
);
view_state.tab_scroll_offset = new_scroll_offset;
}
/// Synchronize viewports for all scroll sync groups
///
/// This syncs the inactive split's viewport to match the active split's position.
/// By deriving from the active split's actual viewport, we capture all viewport
/// changes regardless of source (scroll events, cursor movements, etc.).
pub(super) fn sync_scroll_groups(&mut self) {
let active_split = self.split_manager.active_split();
let group_count = self.scroll_sync_manager.groups().len();
if group_count > 0 {
tracing::debug!(
"sync_scroll_groups: active_split={:?}, {} groups",
active_split,
group_count
);
}
// Collect sync info: for each group where active split participates,
// get the active split's current line position
let sync_info: Vec<_> = self
.scroll_sync_manager
.groups()
.iter()
.filter_map(|group| {
tracing::debug!(
"sync_scroll_groups: checking group {}, left={:?}, right={:?}",
group.id,
group.left_split,
group.right_split
);
if !group.contains_split(active_split.into()) {
tracing::debug!(
"sync_scroll_groups: active split {:?} not in group",
active_split
);
return None;
}
// Get active split's current viewport top_byte
let active_top_byte = self
.split_view_states
.get(&active_split)?
.viewport
.top_byte;
// Get active split's buffer to convert bytes → line
let active_buffer_id = self.split_manager.buffer_for_split(active_split)?;
let buffer_state = self.buffers.get(&active_buffer_id)?;
let buffer_len = buffer_state.buffer.len();
let active_line = buffer_state.buffer.get_line_number(active_top_byte);
tracing::debug!(
"sync_scroll_groups: active_split={:?}, buffer_id={:?}, top_byte={}, buffer_len={}, active_line={}",
active_split,
active_buffer_id,
active_top_byte,
buffer_len,
active_line
);
// Determine the other split and compute its target line
let (other_split, other_line) = if group.is_left_split(active_split.into()) {
// Active is left, sync right
(group.right_split, group.left_to_right_line(active_line))
} else {
// Active is right, sync left
(group.left_split, group.right_to_left_line(active_line))
};
tracing::debug!(
"sync_scroll_groups: syncing other_split={:?} to line {}",
other_split,
other_line
);
Some((other_split, other_line))
})
.collect();
// Apply sync to other splits
for (other_split, target_line) in sync_info {
let other_leaf = LeafId(other_split);
if let Some(buffer_id) = self.split_manager.buffer_for_split(other_leaf) {
if let Some(state) = self.buffers.get_mut(&buffer_id) {
let buffer = &mut state.buffer;
if let Some(view_state) = self.split_view_states.get_mut(&other_leaf) {
view_state.viewport.scroll_to(buffer, target_line);
}
}
}
}
// Same-buffer scroll sync: when two splits show the same buffer (e.g., source
// vs compose mode), sync the inactive split's viewport to match the active
// split's scroll position. Gated on the user-togglable scroll sync flag.
//
// We copy top_byte directly for the general case. At the bottom edge the
// two splits may disagree because compose mode has soft-break virtual lines.
// Rather than computing the correct position here (where view lines aren't
// available), we set a flag and let `render_buffer_in_split` fix it up using
// the same view-line-based logic that `ensure_visible_in_layout` uses.
let active_buffer_id = if self.same_buffer_scroll_sync {
self.split_manager.buffer_for_split(active_split)
} else {
None
};
if let Some(active_buf_id) = active_buffer_id {
let active_top_byte = self
.split_view_states
.get(&active_split)
.map(|vs| vs.viewport.top_byte);
let active_viewport_height = self
.split_view_states
.get(&active_split)
.map(|vs| vs.viewport.visible_line_count())
.unwrap_or(0);
if let Some(top_byte) = active_top_byte {
// Find other splits showing the same buffer (not in an explicit sync group)
let other_splits: Vec<_> = self
.split_view_states
.keys()
.filter(|&&s| {
s != active_split
&& self.split_manager.buffer_for_split(s) == Some(active_buf_id)
&& !self.scroll_sync_manager.is_split_synced(s.into())
})
.copied()
.collect();
if !other_splits.is_empty() {
// Detect whether the active split is at the bottom of the
// buffer (remaining lines fit within the viewport).
let at_bottom = if let Some(state) = self.buffers.get_mut(&active_buf_id) {
let mut iter = state.buffer.line_iterator(top_byte, 80);
let mut lines_remaining = 0;
while iter.next_line().is_some() {
lines_remaining += 1;
if lines_remaining > active_viewport_height {
break;
}
}
lines_remaining <= active_viewport_height
} else {
false
};
for other_split in other_splits {
if let Some(view_state) = self.split_view_states.get_mut(&other_split) {
view_state.viewport.top_byte = top_byte;
// At the bottom edge, tell the render pass to
// adjust using view lines (soft-break-aware).
view_state.viewport.sync_scroll_to_end = at_bottom;
}
}
}
}
}
}
/// Pre-sync ensure_visible for scroll sync groups
///
/// When the active split is in a scroll sync group, we need to update its viewport
/// BEFORE sync_scroll_groups runs. This ensures cursor movements like 'G' (go to end)
/// properly sync to the other split.
///
/// After updating the active split's viewport, we mark the OTHER splits in the group
/// to skip ensure_visible so the sync position isn't undone during rendering.
pub(super) fn pre_sync_ensure_visible(&mut self, active_split: LeafId) {
// Check if active split is in any scroll sync group
let group_info = self
.scroll_sync_manager
.find_group_for_split(active_split.into())
.map(|g| (g.left_split, g.right_split));
if let Some((left_split, right_split)) = group_info {
// Get the active split's buffer and update its viewport
if let Some(buffer_id) = self.split_manager.buffer_for_split(active_split) {
if let Some(state) = self.buffers.get_mut(&buffer_id) {
if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
// Update viewport to show cursor
view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
tracing::debug!(
"pre_sync_ensure_visible: updated active split {:?} viewport, top_byte={}",
active_split,
view_state.viewport.top_byte
);
}
}
}
// Mark the OTHER split to skip ensure_visible so the sync position isn't undone
let active_sid: SplitId = active_split.into();
let other_split: SplitId = if active_sid == left_split {
right_split
} else {
left_split
};
if let Some(view_state) = self.split_view_states.get_mut(&LeafId(other_split)) {
view_state.viewport.set_skip_ensure_visible();
tracing::debug!(
"pre_sync_ensure_visible: marked other split {:?} to skip ensure_visible",
other_split
);
}
}
// Same-buffer scroll sync: also mark other splits showing the same buffer
// to skip ensure_visible, so our sync_scroll_groups position isn't undone.
if !self.same_buffer_scroll_sync {
// Scroll sync disabled — don't interfere with other splits.
} else if let Some(active_buf_id) = self.split_manager.buffer_for_split(active_split) {
let other_same_buffer_splits: Vec<_> = self
.split_view_states
.keys()
.filter(|&&s| {
s != active_split
&& self.split_manager.buffer_for_split(s) == Some(active_buf_id)
&& !self.scroll_sync_manager.is_split_synced(s.into())
})
.copied()
.collect();
for other_split in other_same_buffer_splits {
if let Some(view_state) = self.split_view_states.get_mut(&other_split) {
view_state.viewport.set_skip_ensure_visible();
}
}
}
}
}