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
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! Buffer group management.
//!
//! A buffer group presents multiple splits/buffers as a single tab.
//! Each panel is a real buffer with its own viewport and scrollbar.
//! The group appears as one entry in the tab bar and buffer list.
use crate::app::types::{BufferGroup, BufferGroupId, GroupLayoutNode};
use crate::model::event::{BufferId, LeafId, SplitDirection};
use crate::view::split::SplitViewState;
use fresh_core::api::BufferGroupResult;
use std::collections::HashMap;
/// Layout description deserialized from plugin JSON.
#[derive(Debug, serde::Deserialize)]
#[serde(tag = "type")]
enum LayoutDesc {
#[serde(rename = "scrollable")]
Scrollable {
id: String,
/// Whether this panel responds to scroll events. Defaults to true
/// for scrollable panels.
scrollable: Option<bool>,
},
#[serde(rename = "fixed")]
Fixed {
id: String,
height: u16,
/// Whether this panel responds to scroll events. Defaults to false
/// for fixed-height panels — their content is pinned to the panel
/// size, so mouse-wheel scroll is a no-op and no scrollbar is drawn.
/// Callers can override by passing `"scrollable": true`.
scrollable: Option<bool>,
},
#[serde(rename = "split")]
Split {
direction: String, // "h" or "v"
ratio: f32,
first: Box<LayoutDesc>,
second: Box<LayoutDesc>,
},
}
impl super::Editor {
/// Create a buffer group from a layout description.
///
/// Builds a `SplitNode::Grouped` wrapping the panel layout and stores
/// it in `grouped_subtrees`, then adds a `TabTarget::Group(group_leaf_id)`
/// entry to the current split's tab bar. The main split tree is NOT
/// modified — the group's subtree is dispatched to at render time when
/// the current split's active target is this group.
pub(super) fn create_buffer_group(
&mut self,
name: String,
mode: String,
layout_json: String,
) -> Result<BufferGroupResult, String> {
use crate::view::split::{SplitNode, TabTarget};
// Parse layout
let desc: LayoutDesc =
serde_json::from_str(&layout_json).map_err(|e| format!("Invalid layout: {}", e))?;
// Allocate group ID
let group_id = BufferGroupId(self.next_buffer_group_id);
self.next_buffer_group_id += 1;
// Build buffers for each leaf in the layout
let mut panel_buffers: HashMap<String, BufferId> = HashMap::new();
let mut panel_splits: HashMap<String, LeafId> = HashMap::new();
let layout = self.build_group_layout(&desc, &mode, &mut panel_buffers)?;
// Build the inner split tree for the group
let inner_tree = self.build_split_tree(&layout, &mut panel_splits)?;
// Determine the active inner leaf (first scrollable panel, fallback to any leaf)
let active_inner_leaf = find_first_scrollable_leaf(&layout, &panel_splits)
.or_else(|| panel_splits.values().next().copied())
.ok_or("No panels in layout")?;
// Allocate a LeafId for the Grouped node itself. This is what the
// tab bar uses to reference this group (`TabTarget::Group(group_leaf_id)`).
let group_leaf_id = LeafId(self.split_manager.allocate_split_id());
// Build the Grouped SplitNode and stash it in the side map.
let grouped_node = SplitNode::Grouped {
split_id: group_leaf_id,
name: name.clone(),
layout: Box::new(inner_tree),
active_inner_leaf,
};
self.grouped_subtrees.insert(group_leaf_id, grouped_node);
// Create SplitViewState for each inner panel leaf
let (tw, th) = (self.terminal_width, self.terminal_height);
for (panel_name, leaf_id) in &panel_splits {
let buffer_id = *panel_buffers
.get(panel_name)
.ok_or(format!("Panel '{}' has no buffer", panel_name))?;
let mut vs = SplitViewState::with_buffer(tw, th, buffer_id);
// All panels inside a group suppress chrome — the parent split's
// tab bar is the only tab bar shown.
vs.suppress_chrome = true;
vs.hide_tilde = true;
if let Some(bs) = vs.keyed_states.get_mut(&buffer_id) {
bs.show_line_numbers = false;
bs.highlight_current_line = false;
}
self.split_view_states.insert(*leaf_id, vs);
}
// Mark all panel buffers as hidden from tabs so they don't appear
// in quick-switch or the buffer list.
for buffer_id in panel_buffers.values() {
if let Some(meta) = self.buffer_metadata.get_mut(buffer_id) {
meta.hidden_from_tabs = true;
}
}
// Remove panel buffers from every OTHER split's open_buffers AND
// keyed_states. create_virtual_buffer adds them to the active split
// when each was created; leaving them there makes the outer split
// carry a stale cursor entry for the panel buffer, which later
// collides with the panel's own view state in any lookup that
// scans split_view_states by buffer id.
let hidden_panel_ids: Vec<BufferId> = panel_buffers.values().copied().collect();
let panel_leaf_ids: std::collections::HashSet<LeafId> =
panel_splits.values().copied().collect();
for (leaf_id, vs) in self.split_view_states.iter_mut() {
if panel_leaf_ids.contains(leaf_id) {
// The panel's own view state needs its buffer.
continue;
}
vs.open_buffers.retain(|t| match t {
TabTarget::Buffer(b) => !hidden_panel_ids.contains(b),
TabTarget::Group(_) => true,
});
vs.keyed_states
.retain(|bid, _| !hidden_panel_ids.contains(bid));
}
// Add the group as a tab in the CURRENT split's tab bar and make it
// the active tab. (The main split tree is untouched — the group's
// layout lives in `grouped_subtrees` and is dispatched at render time.)
let current_split_id = self.split_manager.active_split();
if let Some(current_vs) = self.split_view_states.get_mut(¤t_split_id) {
current_vs.add_group(group_leaf_id);
current_vs.set_active_group_tab(group_leaf_id);
current_vs.focused_group_leaf = Some(active_inner_leaf);
}
// Register the group metadata
let group = BufferGroup {
id: group_id,
name: name.clone(),
mode,
layout,
panel_buffers: panel_buffers.clone(),
panel_splits,
representative_split: Some(group_leaf_id),
};
// Register reverse mapping
for buffer_id in panel_buffers.values() {
self.buffer_to_group.insert(*buffer_id, group_id);
}
self.buffer_groups.insert(group_id, group);
// Build result
let panels: HashMap<String, u64> = panel_buffers
.iter()
.map(|(name, bid)| (name.clone(), bid.0 as u64))
.collect();
Ok(BufferGroupResult {
group_id: group_id.0 as u64,
panels,
})
}
/// Build a SplitNode tree directly from a GroupLayoutNode.
/// Populates panel_splits with leaf_id for each panel.
fn build_split_tree(
&mut self,
node: &GroupLayoutNode,
panel_splits: &mut HashMap<String, crate::model::event::LeafId>,
) -> Result<crate::view::split::SplitNode, String> {
use crate::model::event::LeafId;
use crate::view::split::SplitNode;
match node {
GroupLayoutNode::Scrollable {
id,
buffer_id: Some(bid),
..
}
| GroupLayoutNode::Fixed {
id,
buffer_id: Some(bid),
..
} => {
let split_id = self.split_manager.allocate_split_id();
panel_splits.insert(id.clone(), LeafId(split_id));
Ok(SplitNode::leaf(*bid, split_id))
}
GroupLayoutNode::Scrollable {
buffer_id: None, ..
}
| GroupLayoutNode::Fixed {
buffer_id: None, ..
} => Err("Layout leaf has no buffer_id".to_string()),
GroupLayoutNode::Split {
direction,
ratio,
first,
second,
} => {
let first_node = self.build_split_tree(first, panel_splits)?;
let second_node = self.build_split_tree(second, panel_splits)?;
let split_id = self.split_manager.allocate_split_id();
let mut split =
SplitNode::split(*direction, first_node, second_node, *ratio, split_id);
// Apply fixed sizes from children
let fixed_first_size = fixed_height_of(first);
let fixed_second_size = fixed_height_of(second);
if let SplitNode::Split {
fixed_first,
fixed_second,
..
} = &mut split
{
*fixed_first = fixed_first_size;
*fixed_second = fixed_second_size;
}
Ok(split)
}
}
}
/// Build a GroupLayoutNode from a LayoutDesc, creating buffers for each leaf.
fn build_group_layout(
&mut self,
desc: &LayoutDesc,
mode: &str,
panel_buffers: &mut HashMap<String, BufferId>,
) -> Result<GroupLayoutNode, String> {
match desc {
LayoutDesc::Scrollable { id, scrollable } => {
let scrollable = scrollable.unwrap_or(true);
let buffer_id =
self.create_virtual_buffer(format!("*{}*", id), mode.to_string(), true);
if let Some(state) = self.buffers.get_mut(&buffer_id) {
state.show_cursors = false;
state.editing_disabled = true;
state.scrollable = scrollable;
state.margins.configure_for_line_numbers(false);
}
panel_buffers.insert(id.clone(), buffer_id);
Ok(GroupLayoutNode::Scrollable {
id: id.clone(),
buffer_id: Some(buffer_id),
split_id: None,
})
}
LayoutDesc::Fixed {
id,
height,
scrollable,
} => {
let scrollable = scrollable.unwrap_or(false);
let buffer_id =
self.create_virtual_buffer(format!("*{}*", id), mode.to_string(), true);
if let Some(state) = self.buffers.get_mut(&buffer_id) {
state.show_cursors = false;
state.editing_disabled = true;
state.scrollable = scrollable;
state.margins.configure_for_line_numbers(false);
}
panel_buffers.insert(id.clone(), buffer_id);
Ok(GroupLayoutNode::Fixed {
id: id.clone(),
height: *height,
buffer_id: Some(buffer_id),
split_id: None,
})
}
LayoutDesc::Split {
direction,
ratio,
first,
second,
} => {
let dir = if direction == "h" {
SplitDirection::Vertical // "h" = horizontal layout = vertical split line
} else {
SplitDirection::Horizontal
};
let first_node = self.build_group_layout(first, mode, panel_buffers)?;
let second_node = self.build_group_layout(second, mode, panel_buffers)?;
Ok(GroupLayoutNode::Split {
direction: dir,
ratio: *ratio,
first: Box::new(first_node),
second: Box::new(second_node),
})
}
}
}
/// Set content on a panel within a buffer group.
pub(super) fn set_panel_content(
&mut self,
group_id: usize,
panel_name: String,
entries: Vec<fresh_core::text_property::TextPropertyEntry>,
) {
let bg_id = BufferGroupId(group_id);
let buffer_id = self
.buffer_groups
.get(&bg_id)
.and_then(|g| g.panel_buffers.get(&panel_name).copied());
if let Some(buffer_id) = buffer_id {
if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
tracing::error!("Failed to set panel '{}' content: {}", panel_name, e);
}
} else {
tracing::warn!("Panel '{}' not found in group {}", panel_name, group_id);
}
}
/// Close a buffer group — remove the Grouped subtree, close all panel
/// buffers, and remove the group tab from any split's tab bar.
pub(super) fn close_buffer_group(&mut self, group_id: usize) {
use crate::view::split::TabTarget;
let bg_id = BufferGroupId(group_id);
if let Some(group) = self.buffer_groups.remove(&bg_id) {
// Remove reverse mappings
for buffer_id in group.panel_buffers.values() {
self.buffer_to_group.remove(buffer_id);
}
// Find the group_leaf_id (it's the `representative_split` now).
if let Some(group_leaf_id) = group.representative_split {
// Remove the Grouped subtree from the side map
self.grouped_subtrees.remove(&group_leaf_id);
// Remove the group tab from all splits' tab bars and clear
// any active/focused group markers that point at this group.
for vs in self.split_view_states.values_mut() {
vs.open_buffers
.retain(|t| *t != TabTarget::Group(group_leaf_id));
vs.remove_group_from_history(group_leaf_id);
if vs.active_group_tab == Some(group_leaf_id) {
vs.active_group_tab = None;
}
if let Some(focused) = vs.focused_group_leaf {
if group.panel_splits.values().any(|&l| l == focused) {
vs.focused_group_leaf = None;
}
}
}
}
// Clean up SplitViewState for inner panel leaves
for split_id in group.panel_splits.values() {
self.split_view_states.remove(split_id);
}
// Close all panel buffers
for buffer_id in group.panel_buffers.values() {
if let Err(e) = self.close_buffer(*buffer_id) {
tracing::warn!("Failed to close panel buffer {:?}: {}", buffer_id, e);
}
}
// Ensure the active split now has a valid active_target.
// If it was the group's tab, switch to the first available buffer tab.
let active_split = self.split_manager.active_split();
if let Some(vs) = self.split_view_states.get(&active_split) {
if let Some(first_buf) = vs.buffer_tab_ids().next() {
let _ = first_buf; // active_buffer is per-leaf; already set
}
}
}
}
/// Focus a specific panel in a buffer group.
///
/// If the panel's inner leaf is not in the main split tree (side-map
/// approach), this activates the group tab on whichever split hosts it
/// and marks the panel's leaf as the focused inner leaf.
pub(super) fn focus_panel(&mut self, group_id: usize, panel_name: String) {
let bg_id = BufferGroupId(group_id);
let (group_leaf_id, inner_leaf) = match self.buffer_groups.get(&bg_id) {
Some(group) => {
let Some(&inner) = group.panel_splits.get(&panel_name) else {
return;
};
let Some(leaf) = group.representative_split else {
return;
};
(leaf, inner)
}
None => return,
};
// Find the host split whose open_buffers contains this group tab.
let host_split = self
.split_view_states
.iter()
.find(|(_, vs)| vs.has_group(group_leaf_id))
.map(|(sid, _)| *sid);
if let Some(host_split) = host_split {
// Ensure the host split is the active one.
self.split_manager.set_active_split(host_split);
if let Some(vs) = self.split_view_states.get_mut(&host_split) {
vs.active_group_tab = Some(group_leaf_id);
vs.focused_group_leaf = Some(inner_leaf);
}
// Persist the choice on the SplitNode so a tab-away/back round
// trip restores the same panel — `activate_group_tab` reads
// this field when re-focusing the group.
if let Some(crate::view::split::SplitNode::Grouped {
active_inner_leaf, ..
}) = self.grouped_subtrees.get_mut(&group_leaf_id)
{
*active_inner_leaf = inner_leaf;
}
// Transfer focus away from File Explorer (or any other context)
// to the editor, since we're explicitly focusing a panel.
self.key_context = crate::input::keybindings::KeyContext::Normal;
}
}
/// Activate a group tab by its Grouped-node LeafId in the given split.
/// Records the group as the split's active tab so the group's layout
/// becomes visible in that split's content area, and moves keyboard
/// focus to the group's active inner leaf. If `split_id` is not the
/// currently active split (e.g. the user clicked a group tab in a
/// non-focused pane), focus is transferred to it — tab clicks are
/// commitment gestures pointing at the clicked pane.
pub(crate) fn activate_group_tab(&mut self, split_id: LeafId, group_leaf: LeafId) {
// Find the inner active leaf and its buffer from the stored Grouped node.
let Some(crate::view::split::SplitNode::Grouped {
active_inner_leaf, ..
}) = self.grouped_subtrees.get(&group_leaf)
else {
return;
};
let inner_leaf = *active_inner_leaf;
// If activating a group tab in a non-focused split, transfer focus
// to that split first so subsequent keyboard input routes to the
// group's inner panel rather than the previously-active pane. This
// mirrors how clicking a buffer tab in another split moves focus.
if self.split_manager.active_split() != split_id {
self.promote_preview_if_not_in_split(split_id);
if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
self.key_context = crate::input::keybindings::KeyContext::Normal;
}
self.split_manager.set_active_split(split_id);
}
// Record the group as the active-tab and focused inner leaf for
// this split. The inner leaf is NOT in the main split tree — it
// only exists inside the stashed Grouped subtree — so focus is
// routed via `focused_group_leaf` rather than `focus_split`.
if let Some(vs) = self.split_view_states.get_mut(&split_id) {
vs.active_group_tab = Some(group_leaf);
vs.focused_group_leaf = Some(inner_leaf);
}
}
/// Look up the ratio of a split container that lives inside one of the
/// stashed Grouped subtrees (i.e. not in the main split tree). Returns
/// `None` if no grouped subtree contains this container.
pub(crate) fn grouped_split_ratio(
&self,
container: crate::model::event::ContainerId,
) -> Option<f32> {
use crate::view::split::SplitNode;
for node in self.grouped_subtrees.values() {
if let Some(SplitNode::Split { ratio, .. }) = node.find(container.into()) {
return Some(*ratio);
}
}
None
}
/// Set the ratio of a split container that lives inside a stashed
/// Grouped subtree. Returns `true` if the container was found and
/// updated.
pub(crate) fn set_grouped_split_ratio(
&mut self,
container: crate::model::event::ContainerId,
new_ratio: f32,
) -> bool {
use crate::view::split::SplitNode;
for node in self.grouped_subtrees.values_mut() {
if let Some(SplitNode::Split { ratio, .. }) = node.find_mut(container.into()) {
*ratio = new_ratio.clamp(0.1, 0.9);
return true;
}
}
false
}
/// Close a buffer group by its Grouped-node LeafId (used by tab close button).
pub(crate) fn close_buffer_group_by_leaf(&mut self, group_leaf: LeafId) {
// Find the BufferGroupId whose stored representative_split matches
// this Grouped node's LeafId.
let bg_id_opt = self
.buffer_groups
.iter()
.find(|(_, g)| g.representative_split == Some(group_leaf))
.map(|(id, _)| id.0);
if let Some(bg_id) = bg_id_opt {
self.close_buffer_group(bg_id);
}
}
}
/// Get the fixed height of a layout node if it's a Fixed leaf.
fn fixed_height_of(node: &GroupLayoutNode) -> Option<u16> {
match node {
GroupLayoutNode::Fixed { height, .. } => Some(*height),
_ => None,
}
}
impl super::Editor {
/// Whether the given buffer is marked non-scrollable. Buffer-group
/// panels can set `scrollable: false` (and Fixed panels default to
/// it) so the mouse wheel is a no-op and no scrollbar is drawn.
pub(crate) fn is_non_scrollable_buffer(&self, buffer_id: BufferId) -> bool {
self.buffers.get(&buffer_id).is_some_and(|s| !s.scrollable)
}
}
/// Find the first scrollable leaf in the layout tree.
fn find_first_scrollable_name(node: &GroupLayoutNode) -> Option<String> {
match node {
GroupLayoutNode::Scrollable { id, .. } => Some(id.clone()),
GroupLayoutNode::Fixed { .. } => None,
GroupLayoutNode::Split { first, second, .. } => {
find_first_scrollable_name(first).or_else(|| find_first_scrollable_name(second))
}
}
}
/// Find the first scrollable leaf's LeafId from the panel_splits map.
fn find_first_scrollable_leaf(
node: &GroupLayoutNode,
panel_splits: &HashMap<String, LeafId>,
) -> Option<LeafId> {
find_first_scrollable_name(node).and_then(|name| panel_splits.get(&name).copied())
}