use crate::backend::BackendWidget;
use crate::compose::focus_group::FocusGroup;
use crate::event::Rect;
use crate::primitives::drop_zone::{
drop_zone_hit_test, drop_zone_overlay, DropEdge, DropGroupRect, DropOverlay, DropZone,
DropZoneHit, DropZoneKind,
};
use crate::primitives::split::{Split, SplitDirection};
#[allow(deprecated)]
use crate::primitives::tab_bar::{TabBar, TabBarHits, TabBarSegment, TabItem};
use crate::types::WidgetId;
use crate::Backend;
#[derive(Clone, Debug, PartialEq)]
pub enum GroupLayout {
Leaf(usize),
Split {
direction: SplitDirection,
ratio: f32,
first: Box<GroupLayout>,
second: Box<GroupLayout>,
},
}
impl GroupLayout {
pub fn leaf_count(&self) -> usize {
match self {
GroupLayout::Leaf(_) => 1,
GroupLayout::Split { first, second, .. } => first.leaf_count() + second.leaf_count(),
}
}
fn contains_leaf(&self, idx: usize) -> bool {
match self {
GroupLayout::Leaf(k) => *k == idx,
GroupLayout::Split { first, second, .. } => {
first.contains_leaf(idx) || second.contains_leaf(idx)
}
}
}
fn remove_leaf(self, pane_idx: usize) -> Option<Self> {
match self {
GroupLayout::Leaf(k) if k == pane_idx => None,
GroupLayout::Leaf(k) => Some(GroupLayout::Leaf(k)),
GroupLayout::Split {
direction,
ratio,
first,
second,
} => {
if first.contains_leaf(pane_idx) {
match first.remove_leaf(pane_idx) {
None => Some(*second),
Some(new_first) => Some(GroupLayout::Split {
direction,
ratio,
first: Box::new(new_first),
second,
}),
}
} else {
match second.remove_leaf(pane_idx) {
None => Some(*first),
Some(new_second) => Some(GroupLayout::Split {
direction,
ratio,
first,
second: Box::new(new_second),
}),
}
}
}
}
}
fn shift_indices_down_above(self, threshold: usize) -> Self {
match self {
GroupLayout::Leaf(k) => GroupLayout::Leaf(if k > threshold { k - 1 } else { k }),
GroupLayout::Split {
direction,
ratio,
first,
second,
} => GroupLayout::Split {
direction,
ratio,
first: Box::new(first.shift_indices_down_above(threshold)),
second: Box::new(second.shift_indices_down_above(threshold)),
},
}
}
fn collect_leaves(&self, out: &mut Vec<usize>) {
match self {
GroupLayout::Leaf(k) => out.push(*k),
GroupLayout::Split { first, second, .. } => {
first.collect_leaves(out);
second.collect_leaves(out);
}
}
}
fn validate_for_pane_count(&self, n: usize) -> Result<(), String> {
let mut leaves = Vec::with_capacity(n);
self.collect_leaves(&mut leaves);
if leaves.len() != n {
return Err(format!(
"layout has {} leaf nodes but {} panes were provided",
leaves.len(),
n
));
}
let mut seen = vec![false; n];
for &idx in &leaves {
if idx >= n {
return Err(format!(
"leaf index {idx} is out of range for {n} panes (indices must be 0..{n})"
));
}
if seen[idx] {
return Err(format!(
"leaf index {idx} appears more than once in the layout"
));
}
seen[idx] = true;
}
Ok(())
}
fn shift_indices_up_from(self, threshold: usize) -> Self {
match self {
GroupLayout::Leaf(k) => GroupLayout::Leaf(if k >= threshold { k + 1 } else { k }),
GroupLayout::Split {
direction,
ratio,
first,
second,
} => GroupLayout::Split {
direction,
ratio,
first: Box::new(first.shift_indices_up_from(threshold)),
second: Box::new(second.shift_indices_up_from(threshold)),
},
}
}
fn insert_before_leaf(self, target: usize, new_idx: usize, dir: SplitDirection) -> Self {
match self {
GroupLayout::Leaf(k) if k == target => GroupLayout::Split {
direction: dir,
ratio: 0.5,
first: Box::new(GroupLayout::Leaf(new_idx)),
second: Box::new(GroupLayout::Leaf(k)),
},
GroupLayout::Leaf(k) => GroupLayout::Leaf(k),
GroupLayout::Split {
direction,
ratio,
first,
second,
} => GroupLayout::Split {
direction,
ratio,
first: Box::new(first.insert_before_leaf(target, new_idx, dir)),
second: Box::new(second.insert_before_leaf(target, new_idx, dir)),
},
}
}
fn insert_after_leaf(self, target: usize, new_idx: usize, dir: SplitDirection) -> Self {
match self {
GroupLayout::Leaf(k) if k == target => GroupLayout::Split {
direction: dir,
ratio: 0.5,
first: Box::new(GroupLayout::Leaf(k)),
second: Box::new(GroupLayout::Leaf(new_idx)),
},
GroupLayout::Leaf(k) => GroupLayout::Leaf(k),
GroupLayout::Split {
direction,
ratio,
first,
second,
} => GroupLayout::Split {
direction,
ratio,
first: Box::new(first.insert_after_leaf(target, new_idx, dir)),
second: Box::new(second.insert_after_leaf(target, new_idx, dir)),
},
}
}
}
struct DividerInfo {
divider_bounds: Rect,
direction: SplitDirection,
split_bounds: Rect,
ratio: f32,
}
pub struct PaneTab {
pub id: String,
pub label: String,
pub closable: bool,
pub content: Box<dyn BackendWidget>,
}
pub struct Pane {
pub id: String,
tabs: Vec<PaneTab>,
active_tab_id: String,
tab_scroll_offset: usize,
}
impl Pane {
pub fn new(
id: impl Into<String>,
tabs: Vec<PaneTab>,
active_tab_id: impl Into<String>,
) -> Self {
let active_tab_id = active_tab_id.into();
let resolved = if tabs.iter().any(|t| t.id == active_tab_id) {
active_tab_id
} else {
tabs.first().map(|t| t.id.clone()).unwrap_or_default()
};
Self {
id: id.into(),
tabs,
active_tab_id: resolved,
tab_scroll_offset: 0,
}
}
pub fn active_tab_id(&self) -> &str {
&self.active_tab_id
}
pub fn tabs(&self) -> &[PaneTab] {
&self.tabs
}
fn build_tab_bar(&self, pane_idx: usize) -> TabBar {
let items: Vec<TabItem> = self
.tabs
.iter()
.map(|t| TabItem {
label: t.label.clone(),
is_active: t.id == self.active_tab_id,
is_dirty: false,
is_preview: false,
is_closable: t.closable,
})
.collect();
TabBar {
id: WidgetId::new(format!("tg:pane{}:tabs", pane_idx)),
tabs: items,
scroll_offset: self.tab_scroll_offset,
right_segments: vec![TabBarSegment {
text: " + ".to_string(),
width_cells: 3,
id: Some(WidgetId::new(format!("tg:pane{}:new-tab", pane_idx))),
is_active: false,
}],
active_accent: None,
show_tab_close: self.tabs.iter().any(|t| t.closable),
compact: false,
}
}
fn activate_tab(&mut self, tab_id: &str) -> bool {
if self.tabs.iter().any(|t| t.id == tab_id) {
self.active_tab_id = tab_id.to_string();
self.tab_scroll_offset = 0;
true
} else {
false
}
}
fn remove_tab(&mut self, tab_id: &str) -> bool {
let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else {
return false;
};
self.tabs.remove(idx);
if self.active_tab_id == tab_id {
let new_idx = if idx > 0 { idx - 1 } else { 0 };
self.active_tab_id = self
.tabs
.get(new_idx)
.map(|t| t.id.clone())
.unwrap_or_default();
}
true
}
fn is_empty(&self) -> bool {
self.tabs.is_empty()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TabGroupEvent {
TabActivated { pane_idx: usize, tab_id: String },
TabClosed { pane_idx: usize, tab_id: String },
PaneCollapsed {
pane_idx: usize,
},
PaneAdded { pane_idx: usize },
PaneFocused { pane_idx: usize },
DividerResized { divider_idx: usize },
NewTabRequested { pane_idx: usize },
TabReordered {
pane_idx: usize,
tab_id: String,
from_idx: usize,
to_idx: usize,
},
TabMovedToPane {
from_pane_idx: usize,
to_pane_idx: usize,
tab_id: String,
insert_idx: usize,
},
TabSplitToNewPane {
from_pane_idx: usize,
tab_id: String,
target_pane_idx: usize,
edge: DropEdge,
new_pane_idx: usize,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct TabGroupLayout {
pub pane_bounds: Vec<Rect>,
pub strip_bounds: Vec<Rect>,
pub content_bounds: Vec<Rect>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PaneDragRect {
pub strip_bounds: Rect,
pub content_bounds: Rect,
pub tab_slots: Vec<(f32, f32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabDragSource {
pub pane_idx: usize,
pub tab_idx: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TabDropInstruction {
NoOp,
Reorder {
pane_idx: usize,
from_idx: usize,
to_idx: usize,
},
MoveToPane {
from_pane_idx: usize,
from_idx: usize,
to_pane_idx: usize,
insert_idx: usize,
source_now_empty: bool,
},
SplitToNewPane {
from_pane_idx: usize,
from_idx: usize,
target_pane_idx: usize,
edge: DropEdge,
split_direction: SplitDirection,
source_now_empty: bool,
},
}
pub fn resolve_tab_drop(
source: TabDragSource,
pane_tab_counts: &[usize],
cursor_x: f32,
cursor_y: f32,
groups: &[DropGroupRect],
tab_bar_height: f32,
) -> TabDropInstruction {
let DropZoneHit::Zone(zone) = drop_zone_hit_test(cursor_x, cursor_y, groups, tab_bar_height)
else {
return TabDropInstruction::NoOp;
};
let from = source.pane_idx;
let to = zone.group_idx;
let cur_idx = source.tab_idx;
let from_count = pane_tab_counts.get(from).copied().unwrap_or(0);
let reorder_insert_idx = if let DropZoneKind::TabReorder(idx) = zone.kind {
Some(idx)
} else {
None
};
match zone.kind {
DropZoneKind::TabReorder(insert_idx) if to == from => {
if cur_idx == insert_idx || cur_idx + 1 == insert_idx {
return TabDropInstruction::NoOp;
}
let adj = if insert_idx > cur_idx {
insert_idx - 1
} else {
insert_idx
};
let adj = adj.min(from_count.saturating_sub(1));
TabDropInstruction::Reorder {
pane_idx: from,
from_idx: cur_idx,
to_idx: adj,
}
}
DropZoneKind::Center if to == from => TabDropInstruction::NoOp,
DropZoneKind::Center | DropZoneKind::TabReorder(_) => {
let to_count = pane_tab_counts.get(to).copied().unwrap_or(0);
let insert_idx = reorder_insert_idx
.map(|idx| idx.min(to_count))
.unwrap_or(to_count);
TabDropInstruction::MoveToPane {
from_pane_idx: from,
from_idx: cur_idx,
to_pane_idx: to,
insert_idx,
source_now_empty: from_count <= 1,
}
}
DropZoneKind::Split(edge) => {
if from_count == 1 && pane_tab_counts.len() == 1 {
return TabDropInstruction::NoOp;
}
let split_direction = match edge {
DropEdge::Left | DropEdge::Right => SplitDirection::Horizontal,
DropEdge::Top | DropEdge::Bottom => SplitDirection::Vertical,
};
TabDropInstruction::SplitToNewPane {
from_pane_idx: from,
from_idx: cur_idx,
target_pane_idx: to,
edge,
split_direction,
source_now_empty: from_count <= 1,
}
}
}
}
pub struct TabGroupController {
panes: Vec<Pane>,
focus: FocusGroup,
layout: GroupLayout,
default_split_direction: SplitDirection,
next_pane_counter: usize,
last_bounds: Option<Rect>,
last_pane_hits: Vec<Option<PaneHitCache>>,
last_dividers: Vec<DividerInfo>,
dragging_divider: Option<usize>,
dragging_tab: Option<TabDragState>,
}
struct PaneHitCache {
#[allow(deprecated)] hits: TabBarHits,
strip_bounds: Rect,
content_bounds: Rect,
}
struct TabDragState {
source_pane_idx: usize,
tab_id: String,
current_zone: Option<DropZone>,
cursor_x: f32,
cursor_y: f32,
}
impl TabGroupController {
pub fn with_pane(
pane_id: impl Into<String>,
tabs: Vec<PaneTab>,
active_tab_id: impl Into<String>,
split_direction: SplitDirection,
) -> Self {
let pane = Pane::new(pane_id, tabs, active_tab_id);
Self {
panes: vec![pane],
focus: FocusGroup::new(1),
layout: GroupLayout::Leaf(0),
default_split_direction: split_direction,
next_pane_counter: 1,
last_bounds: None,
last_pane_hits: (0..1).map(|_| None).collect(),
last_dividers: vec![],
dragging_divider: None,
dragging_tab: None,
}
}
pub fn from_layout(
panes: Vec<Pane>,
layout: GroupLayout,
default_split_direction: SplitDirection,
) -> Result<Self, String> {
let n = panes.len();
if n == 0 {
return Err("panes must be non-empty".into());
}
layout.validate_for_pane_count(n)?;
Ok(Self {
panes,
focus: FocusGroup::new(n),
layout,
default_split_direction,
next_pane_counter: n,
last_bounds: None,
last_pane_hits: (0..n).map(|_| None).collect(),
last_dividers: vec![],
dragging_divider: None,
dragging_tab: None,
})
}
pub fn pane_count(&self) -> usize {
self.panes.len()
}
pub fn pane(&self, idx: usize) -> Option<&Pane> {
self.panes.get(idx)
}
pub fn focused_pane(&self) -> Option<usize> {
self.focus.active()
}
pub fn focus_pane(&mut self, pane_idx: usize) {
if !self.panes.is_empty() {
let clamped = pane_idx.min(self.panes.len() - 1);
self.focus.set_active(Some(clamped));
}
}
pub fn cycle_focus(&mut self, delta: isize) {
self.focus.cycle(delta);
}
pub fn layout(&self) -> &GroupLayout {
&self.layout
}
pub fn add_tab(&mut self, pane_idx: usize, tab: PaneTab) -> bool {
if let Some(pane) = self.panes.get_mut(pane_idx) {
pane.tabs.push(tab);
true
} else {
false
}
}
pub fn add_and_activate_tab(&mut self, pane_idx: usize, tab: PaneTab) -> bool {
let id = tab.id.clone();
if self.add_tab(pane_idx, tab) {
self.panes[pane_idx].activate_tab(&id);
true
} else {
false
}
}
pub fn switch_tab(&mut self, pane_idx: usize, tab_id: &str) -> bool {
self.panes
.get_mut(pane_idx)
.map(|p| p.activate_tab(tab_id))
.unwrap_or(false)
}
pub fn close_tab(&mut self, pane_idx: usize, tab_id: &str) -> Option<TabGroupEvent> {
let pane = self.panes.get_mut(pane_idx)?;
if !pane.remove_tab(tab_id) {
return None;
}
let tab_id_owned = tab_id.to_string();
if pane.is_empty() {
self.collapse_pane(pane_idx);
Some(TabGroupEvent::PaneCollapsed { pane_idx })
} else {
Some(TabGroupEvent::TabClosed {
pane_idx,
tab_id: tab_id_owned,
})
}
}
pub fn add_pane_with_tab(&mut self, pane_id: impl Into<String>, tab: PaneTab) -> usize {
let tab_id = tab.id.clone();
let new_pane = Pane::new(pane_id, vec![tab], tab_id);
let focused = self
.focus
.active()
.unwrap_or_else(|| self.panes.len().saturating_sub(1));
let insert_pos = (focused + 1).min(self.panes.len());
self.layout = self.layout.clone().shift_indices_up_from(insert_pos);
self.panes.insert(insert_pos, new_pane);
self.layout = self.layout.clone().insert_after_leaf(
focused,
insert_pos,
self.default_split_direction,
);
let n = self.panes.len();
self.focus.set_count(n);
self.focus.set_active(Some(insert_pos));
self.last_pane_hits = (0..n).map(|_| None).collect();
self.last_dividers = vec![];
insert_pos
}
pub fn collapse_pane(&mut self, pane_idx: usize) {
if self.panes.len() <= 1 || pane_idx >= self.panes.len() {
return;
}
let new_layout = self
.layout
.clone()
.remove_leaf(pane_idx)
.expect("pane_idx must exist as a leaf in the layout tree");
self.layout = new_layout.shift_indices_down_above(pane_idx);
self.panes.remove(pane_idx);
let n = self.panes.len();
self.focus.set_count(n);
if let Some(fi) = self.focus.active() {
if fi >= n {
self.focus.set_active(n.checked_sub(1));
}
}
self.last_pane_hits = (0..n).map(|_| None).collect();
self.last_dividers = vec![];
}
#[allow(deprecated)] pub fn render(&mut self, backend: &mut dyn Backend, bounds: Rect) -> TabGroupLayout {
if self.panes.is_empty() {
return TabGroupLayout {
pane_bounds: vec![],
strip_bounds: vec![],
content_bounds: vec![],
};
}
self.last_bounds = Some(bounds);
let n = self.panes.len();
let mut pane_bounds_by_idx: Vec<Option<Rect>> = vec![None; n];
self.last_dividers.clear();
layout_recursive(
&self.layout,
backend,
bounds,
&mut pane_bounds_by_idx,
&mut self.last_dividers,
);
for (i, info) in self.last_dividers.iter().enumerate() {
let split = Split {
id: WidgetId::new(format!("tg:div{}", i)),
direction: info.direction,
ratio: info.ratio,
first_min: 0.0,
second_min: 0.0,
};
backend.draw_split(info.split_bounds, &split);
}
let lh = backend.chrome_line_height();
let mut pane_bounds_out = Vec::with_capacity(n);
let mut strip_bounds = Vec::with_capacity(n);
let mut content_bounds = Vec::with_capacity(n);
if self.last_pane_hits.len() != n {
self.last_pane_hits = (0..n).map(|_| None).collect();
}
for (pane_idx, pane) in self.panes.iter_mut().enumerate() {
let pb = pane_bounds_by_idx[pane_idx].unwrap_or(bounds);
let strip = Rect::new(pb.x, pb.y, pb.width, lh);
let content_h = (pb.height - lh).max(0.0);
let content = Rect::new(pb.x, pb.y + lh, pb.width, content_h);
pane_bounds_out.push(pb);
strip_bounds.push(strip);
content_bounds.push(content);
let tab_bar = pane.build_tab_bar(pane_idx);
let hits = backend.draw_tab_bar(strip, &tab_bar, None);
pane.tab_scroll_offset = hits.correct_scroll_offset;
if content.width > 0.0 && content.height > 0.0 {
if let Some(tab_pos) = pane.tabs.iter().position(|t| t.id == pane.active_tab_id) {
pane.tabs[tab_pos].content.render(backend, content);
}
}
self.last_pane_hits[pane_idx] = Some(PaneHitCache {
hits,
strip_bounds: strip,
content_bounds: content,
});
}
let overlay: Option<DropOverlay> = if let Some(drag) = self.dragging_tab.as_ref() {
if drag.current_zone.is_some() {
let ghost_offset = lh;
self.tab_drag_overlay(drag.cursor_x, drag.cursor_y, 2.0, ghost_offset)
} else {
None
}
} else {
None
};
if let Some(ov) = overlay {
backend.draw_drop_overlay(&ov);
}
TabGroupLayout {
pane_bounds: pane_bounds_out,
strip_bounds,
content_bounds,
}
}
#[allow(deprecated)] pub fn handle_click(&mut self, x: f32, y: f32) -> Option<TabGroupEvent> {
let n = self.panes.len();
let click_x = x as f64;
let in_range = |range: (f64, f64)| click_x >= range.0 && click_x < range.1;
for pane_idx in 0..n {
let Some(cache) = self.last_pane_hits[pane_idx].as_ref() else {
continue;
};
let strip = cache.strip_bounds;
let content = cache.content_bounds;
let hits = &cache.hits;
let in_strip = y >= strip.y
&& y < strip.y + strip.height
&& x >= strip.x
&& x < strip.x + strip.width;
if in_strip {
self.focus.set_active(Some(pane_idx));
for (idx, cb) in hits.close_bounds.iter().enumerate() {
if let Some(range) = cb {
if in_range(*range) {
if let Some(tab) = self.panes[pane_idx].tabs.get(idx) {
if tab.closable {
let tab_id = tab.id.clone();
return self.close_tab(pane_idx, &tab_id);
}
}
return None;
}
}
}
for rb in &hits.right_segment_bounds {
if in_range(*rb) {
return Some(TabGroupEvent::NewTabRequested { pane_idx });
}
}
for (idx, range) in hits.slot_positions.iter().enumerate() {
if in_range(*range) {
if let Some(tab) = self.panes[pane_idx].tabs.get(idx) {
let tab_id = tab.id.clone();
if tab_id != self.panes[pane_idx].active_tab_id {
self.panes[pane_idx].activate_tab(&tab_id);
return Some(TabGroupEvent::TabActivated { pane_idx, tab_id });
}
}
return None;
}
}
return None;
}
let in_content = y >= content.y
&& y < content.y + content.height
&& x >= content.x
&& x < content.x + content.width;
if in_content {
if self.focus.active() != Some(pane_idx) {
self.focus.set_active(Some(pane_idx));
return Some(TabGroupEvent::PaneFocused { pane_idx });
}
return None;
}
}
None
}
pub fn handle_drag_start(&mut self, x: f32, y: f32) -> bool {
for (i, info) in self.last_dividers.iter().enumerate() {
let d = &info.divider_bounds;
if x >= d.x && x < d.x + d.width && y >= d.y && y < d.y + d.height {
self.dragging_divider = Some(i);
return true;
}
}
false
}
pub fn handle_drag_move(&mut self, x: f32, y: f32, _bounds: Rect) -> Option<TabGroupEvent> {
let div_idx = self.dragging_divider?;
let info = self.last_dividers.get(div_idx)?;
let new_ratio = match info.direction {
SplitDirection::Horizontal => {
if info.split_bounds.width > 0.0 {
((x - info.split_bounds.x) / info.split_bounds.width).clamp(0.05, 0.95)
} else {
return None;
}
}
SplitDirection::Vertical => {
if info.split_bounds.height > 0.0 {
((y - info.split_bounds.y) / info.split_bounds.height).clamp(0.05, 0.95)
} else {
return None;
}
}
};
let mut counter = 0usize;
let changed =
update_split_ratio_inorder(&mut self.layout, div_idx, new_ratio, &mut counter)
.unwrap_or(false);
if changed {
Some(TabGroupEvent::DividerResized {
divider_idx: div_idx,
})
} else {
None
}
}
pub fn handle_drag_end(&mut self) {
self.dragging_divider = None;
}
#[allow(deprecated)] pub fn drop_group_rects(&self) -> Vec<DropGroupRect> {
self.last_pane_hits
.iter()
.filter_map(|cache| {
let c = cache.as_ref()?;
let strip = c.strip_bounds;
let full = Rect::new(
strip.x,
strip.y,
strip.width,
strip.height + c.content_bounds.height,
);
let tab_slots: Vec<(f32, f32)> = c
.hits
.slot_positions
.iter()
.filter(|(s, e)| *s != 0.0 || *e != 0.0)
.map(|(s, e)| (*s as f32, *e as f32))
.collect();
Some(DropGroupRect {
bounds: full,
tab_slots,
})
})
.collect()
}
#[allow(deprecated)] pub fn set_drag_geometry(&mut self, panes: &[PaneDragRect]) {
let n = self.panes.len();
if self.last_pane_hits.len() != n {
self.last_pane_hits = (0..n).map(|_| None).collect();
}
for (pane_idx, drag_rect) in panes.iter().enumerate().take(n) {
let slot_positions: Vec<(f64, f64)> = drag_rect
.tab_slots
.iter()
.map(|(s, e)| (*s as f64, *e as f64))
.collect();
let n_slots = slot_positions.len();
let hits = crate::primitives::tab_bar::TabBarHits {
slot_positions,
close_bounds: vec![None; n_slots],
right_segment_bounds: vec![],
available_cols: 0,
correct_scroll_offset: 0,
};
self.last_pane_hits[pane_idx] = Some(PaneHitCache {
hits,
strip_bounds: drag_rect.strip_bounds,
content_bounds: drag_rect.content_bounds,
});
}
}
fn strip_height(&self) -> f32 {
self.last_pane_hits
.iter()
.find_map(|c| c.as_ref().map(|c| c.strip_bounds.height))
.unwrap_or(1.0)
}
#[allow(deprecated)] pub fn handle_tab_drag_start(&mut self, x: f32, y: f32) -> bool {
let click_x = x as f64;
let in_range = |range: (f64, f64)| click_x >= range.0 && click_x < range.1;
let n = self.panes.len();
for pane_idx in 0..n {
let Some(cache) = self.last_pane_hits[pane_idx].as_ref() else {
continue;
};
let strip = cache.strip_bounds;
let hits = &cache.hits;
let in_strip = y >= strip.y
&& y < strip.y + strip.height
&& x >= strip.x
&& x < strip.x + strip.width;
if !in_strip {
continue;
}
for range in hits.close_bounds.iter().flatten() {
if in_range(*range) {
return false;
}
}
for rb in &hits.right_segment_bounds {
if in_range(*rb) {
return false;
}
}
for (tab_idx, range) in hits.slot_positions.iter().enumerate() {
if in_range(*range) {
if let Some(tab) = self.panes[pane_idx].tabs.get(tab_idx) {
self.dragging_divider = None;
self.dragging_tab = Some(TabDragState {
source_pane_idx: pane_idx,
tab_id: tab.id.clone(),
current_zone: None,
cursor_x: x,
cursor_y: y,
});
return true;
}
return false;
}
}
}
false
}
pub fn handle_tab_drag_move(&mut self, x: f32, y: f32) -> Option<DropZone> {
self.dragging_tab.as_ref()?;
let groups = self.drop_group_rects();
let tab_bar_h = self.strip_height();
let zone = match drop_zone_hit_test(x, y, &groups, tab_bar_h) {
DropZoneHit::Zone(z) => Some(z),
DropZoneHit::Empty => None,
};
if let Some(drag) = &mut self.dragging_tab {
drag.current_zone = zone.clone();
drag.cursor_x = x;
drag.cursor_y = y;
}
zone
}
pub fn tab_drag_overlay(
&self,
cursor_x: f32,
cursor_y: f32,
bar_thickness: f32,
ghost_offset: f32,
) -> Option<DropOverlay> {
let drag = self.dragging_tab.as_ref()?;
let zone = drag.current_zone.as_ref()?;
let groups = self.drop_group_rects();
let tab_bar_h = self.strip_height();
Some(drop_zone_overlay(
zone,
&groups,
cursor_x,
cursor_y,
tab_bar_h,
bar_thickness,
ghost_offset,
))
}
pub fn handle_tab_drop(&mut self, x: f32, y: f32) -> Vec<TabGroupEvent> {
let Some(drag) = self.dragging_tab.take() else {
return Vec::new();
};
let groups = self.drop_group_rects();
let tab_bar_h = self.strip_height();
let from = drag.source_pane_idx;
let Some(cur_idx) = self.panes[from]
.tabs
.iter()
.position(|t| t.id == drag.tab_id)
else {
return Vec::new();
};
let pane_tab_counts: Vec<usize> = self.panes.iter().map(|p| p.tabs.len()).collect();
let source = TabDragSource {
pane_idx: from,
tab_idx: cur_idx,
};
let instruction = resolve_tab_drop(source, &pane_tab_counts, x, y, &groups, tab_bar_h);
match instruction {
TabDropInstruction::NoOp => Vec::new(),
TabDropInstruction::Reorder {
pane_idx,
from_idx,
to_idx,
} => {
let moved_tab = self.panes[pane_idx].tabs.remove(from_idx);
self.panes[pane_idx].tabs.insert(to_idx, moved_tab);
vec![TabGroupEvent::TabReordered {
pane_idx,
tab_id: drag.tab_id,
from_idx,
to_idx,
}]
}
TabDropInstruction::MoveToPane {
from_pane_idx,
from_idx,
to_pane_idx,
insert_idx,
source_now_empty,
} => {
let moved_tab = self.panes[from_pane_idx].tabs.remove(from_idx);
if self.panes[from_pane_idx].active_tab_id == drag.tab_id {
let fallback = if from_idx > 0 { from_idx - 1 } else { 0 };
self.panes[from_pane_idx].active_tab_id = self.panes[from_pane_idx]
.tabs
.get(fallback)
.map(|t| t.id.clone())
.unwrap_or_default();
}
let tab_id = moved_tab.id.clone();
self.panes[to_pane_idx].tabs.insert(insert_idx, moved_tab);
self.panes[to_pane_idx].active_tab_id = tab_id.clone();
let final_to = if source_now_empty {
self.collapse_pane(from_pane_idx);
if from_pane_idx < to_pane_idx {
to_pane_idx - 1
} else {
to_pane_idx
}
} else {
to_pane_idx
};
let mut events = vec![TabGroupEvent::TabMovedToPane {
from_pane_idx,
to_pane_idx: final_to,
tab_id,
insert_idx,
}];
if source_now_empty {
events.push(TabGroupEvent::PaneCollapsed {
pane_idx: from_pane_idx,
});
}
events
}
TabDropInstruction::SplitToNewPane {
from_pane_idx,
from_idx,
target_pane_idx,
edge,
split_direction,
source_now_empty,
} => {
let moved_tab = self.panes[from_pane_idx].tabs.remove(from_idx);
if self.panes[from_pane_idx].active_tab_id == drag.tab_id {
let fallback = if from_idx > 0 { from_idx - 1 } else { 0 };
self.panes[from_pane_idx].active_tab_id = self.panes[from_pane_idx]
.tabs
.get(fallback)
.map(|t| t.id.clone())
.unwrap_or_default();
}
let tab_id = moved_tab.id.clone();
let original_target = target_pane_idx;
let insert_before = matches!(edge, DropEdge::Left | DropEdge::Top);
let mut adjusted_to = target_pane_idx;
if source_now_empty {
self.collapse_pane(from_pane_idx);
if from_pane_idx < adjusted_to {
adjusted_to -= 1;
}
}
let actual_pos = if insert_before {
adjusted_to
} else {
adjusted_to + 1
};
let new_pane_id = format!("pane:{}", self.next_pane_counter);
self.next_pane_counter += 1;
let new_tab_id = moved_tab.id.clone();
let new_pane = Pane::new(new_pane_id, vec![moved_tab], new_tab_id);
self.layout = self.layout.clone().shift_indices_up_from(actual_pos);
self.panes.insert(actual_pos, new_pane);
if insert_before {
self.layout = self.layout.clone().insert_before_leaf(
adjusted_to + 1,
actual_pos,
split_direction,
);
} else {
self.layout = self.layout.clone().insert_after_leaf(
adjusted_to,
actual_pos,
split_direction,
);
}
let new_n = self.panes.len();
self.focus.set_count(new_n);
self.focus.set_active(Some(actual_pos));
self.last_pane_hits = (0..new_n).map(|_| None).collect();
self.last_dividers = vec![];
let mut events = vec![TabGroupEvent::TabSplitToNewPane {
from_pane_idx,
tab_id,
target_pane_idx: original_target,
edge,
new_pane_idx: actual_pos,
}];
if source_now_empty {
events.push(TabGroupEvent::PaneCollapsed {
pane_idx: from_pane_idx,
});
}
events
}
}
}
pub fn cancel_tab_drag(&mut self) {
self.dragging_tab = None;
}
pub fn is_tab_dragging(&self) -> bool {
self.dragging_tab.is_some()
}
pub fn drop_zone_at(&self, cursor_x: f32, cursor_y: f32) -> Option<DropZone> {
let groups = self.drop_group_rects();
let tab_bar_h = self.strip_height();
match drop_zone_hit_test(cursor_x, cursor_y, &groups, tab_bar_h) {
DropZoneHit::Zone(z) => Some(z),
DropZoneHit::Empty => None,
}
}
}
fn layout_recursive(
tree: &GroupLayout,
backend: &dyn Backend,
bounds: Rect,
pane_bounds: &mut Vec<Option<Rect>>,
dividers: &mut Vec<DividerInfo>,
) {
match tree {
GroupLayout::Leaf(idx) => {
if *idx < pane_bounds.len() {
pane_bounds[*idx] = Some(bounds);
}
}
GroupLayout::Split {
direction,
ratio,
first,
second,
} => {
let split = Split {
id: WidgetId::new("tg:layout"),
direction: *direction,
ratio: *ratio,
first_min: 0.0,
second_min: 0.0,
};
let sl = backend.split_layout(bounds, &split);
layout_recursive(first, backend, sl.first_bounds, pane_bounds, dividers);
dividers.push(DividerInfo {
divider_bounds: sl.divider_bounds,
direction: *direction,
split_bounds: bounds,
ratio: *ratio,
});
layout_recursive(second, backend, sl.second_bounds, pane_bounds, dividers);
}
}
}
fn update_split_ratio_inorder(
tree: &mut GroupLayout,
target: usize,
new_ratio: f32,
counter: &mut usize,
) -> Option<bool> {
match tree {
GroupLayout::Leaf(_) => None,
GroupLayout::Split {
ratio,
first,
second,
..
} => {
if let Some(r) = update_split_ratio_inorder(first, target, new_ratio, counter) {
return Some(r);
}
if *counter == target {
let old = *ratio;
*counter += 1;
*ratio = new_ratio;
return Some((new_ratio - old).abs() >= 0.001);
}
*counter += 1;
update_split_ratio_inorder(second, target, new_ratio, counter)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::tab_bar_hits_from_layout;
use crate::primitives::tab_bar::{SegmentMeasure, TabMeasure};
struct NoOpContent;
impl BackendWidget for NoOpContent {
fn render(&self, _backend: &mut dyn Backend, _rect: Rect) {}
}
fn tab(id: &str, label: &str, closable: bool) -> PaneTab {
PaneTab {
id: id.to_string(),
label: label.to_string(),
closable,
content: Box::new(NoOpContent),
}
}
fn make_group() -> TabGroupController {
TabGroupController::with_pane(
"pane:0",
vec![tab("t0", "main.rs", true), tab("t1", "lib.rs", true)],
"t0",
SplitDirection::Horizontal,
)
}
#[allow(deprecated)] fn prime_pane(
ctrl: &mut TabGroupController,
pane_idx: usize,
strip_x: f32,
strip_y: f32,
bar_w: f32,
tab_w: f32,
close_w: f32,
) {
let Some(pane) = ctrl.panes.get(pane_idx) else {
return;
};
let bar = pane.build_tab_bar(pane_idx);
let n_tabs = pane.tabs.len();
let layout = bar.layout(
bar_w,
1.0,
0.0,
|i| {
if i < n_tabs && pane.tabs[i].closable {
TabMeasure::new(tab_w, close_w)
} else {
TabMeasure::new(tab_w, 0.0)
}
},
|_| SegmentMeasure::new(3.0),
);
let mut hits = tab_bar_hits_from_layout(&layout, &bar);
let ox = strip_x as f64;
for sp in &mut hits.slot_positions {
if *sp != (0.0, 0.0) {
sp.0 += ox;
sp.1 += ox;
}
}
for cb in hits.close_bounds.iter_mut().flatten() {
cb.0 += ox;
cb.1 += ox;
}
for rb in &mut hits.right_segment_bounds {
rb.0 += ox;
rb.1 += ox;
}
let strip = Rect::new(strip_x, strip_y, bar_w, 1.0);
let content = Rect::new(strip_x, strip_y + 1.0, bar_w, 10.0);
ctrl.last_pane_hits[pane_idx] = Some(PaneHitCache {
hits,
strip_bounds: strip,
content_bounds: content,
});
}
#[test]
fn new_controller_has_one_pane_with_tabs() {
let ctrl = make_group();
assert_eq!(ctrl.pane_count(), 1);
assert_eq!(ctrl.panes[0].tabs().len(), 2);
assert_eq!(ctrl.panes[0].active_tab_id(), "t0");
assert_eq!(ctrl.focused_pane(), None); }
#[test]
fn from_layout_single_pane() {
let pane = Pane::new("p0", vec![tab("t0", "main.rs", true)], "t0");
let layout = GroupLayout::Leaf(0);
let ctrl = TabGroupController::from_layout(vec![pane], layout, SplitDirection::Horizontal)
.expect("valid single-pane layout");
assert_eq!(ctrl.pane_count(), 1);
assert_eq!(ctrl.layout, GroupLayout::Leaf(0));
assert_eq!(ctrl.focused_pane(), None); assert_eq!(ctrl.panes[0].active_tab_id(), "t0");
}
#[test]
fn from_layout_two_pane_horizontal() {
let panes = vec![
Pane::new("p0", vec![tab("t0", "a.rs", true)], "t0"),
Pane::new("p1", vec![tab("t1", "b.rs", true)], "t1"),
];
let layout = GroupLayout::Split {
direction: SplitDirection::Horizontal,
ratio: 0.4,
first: Box::new(GroupLayout::Leaf(0)),
second: Box::new(GroupLayout::Leaf(1)),
};
let ctrl = TabGroupController::from_layout(panes, layout.clone(), SplitDirection::Vertical)
.expect("valid two-pane layout");
assert_eq!(ctrl.pane_count(), 2);
assert_eq!(ctrl.layout, layout);
assert_eq!(ctrl.panes[0].id, "p0");
assert_eq!(ctrl.panes[1].id, "p1");
}
#[test]
fn from_layout_three_pane_mixed_direction() {
let panes = vec![
Pane::new("left", vec![tab("tl", "left.rs", true)], "tl"),
Pane::new("top-right", vec![tab("tr", "top.rs", true)], "tr"),
Pane::new("bottom-right", vec![tab("tb", "bottom.rs", true)], "tb"),
];
let layout = GroupLayout::Split {
direction: SplitDirection::Horizontal,
ratio: 0.5,
first: Box::new(GroupLayout::Leaf(0)),
second: Box::new(GroupLayout::Split {
direction: SplitDirection::Vertical,
ratio: 0.5,
first: Box::new(GroupLayout::Leaf(1)),
second: Box::new(GroupLayout::Leaf(2)),
}),
};
let ctrl = TabGroupController::from_layout(panes, layout, SplitDirection::Horizontal)
.expect("valid mixed-direction layout");
assert_eq!(ctrl.pane_count(), 3);
assert_eq!(ctrl.layout.leaf_count(), 3);
assert!(ctrl.layout.contains_leaf(0));
assert!(ctrl.layout.contains_leaf(1));
assert!(ctrl.layout.contains_leaf(2));
match &ctrl.layout {
GroupLayout::Split {
direction: SplitDirection::Horizontal,
second,
..
} => match second.as_ref() {
GroupLayout::Split {
direction: SplitDirection::Vertical,
..
} => {}
other => panic!("expected Vertical nested split, got {other:?}"),
},
other => panic!("expected Horizontal root split, got {other:?}"),
}
}
#[test]
fn from_layout_custom_ratio_preserved() {
let panes = vec![
Pane::new("p0", vec![tab("t0", "a.rs", false)], "t0"),
Pane::new("p1", vec![tab("t1", "b.rs", false)], "t1"),
];
let layout = GroupLayout::Split {
direction: SplitDirection::Horizontal,
ratio: 0.3,
first: Box::new(GroupLayout::Leaf(0)),
second: Box::new(GroupLayout::Leaf(1)),
};
let ctrl = TabGroupController::from_layout(panes, layout, SplitDirection::Horizontal)
.expect("valid");
let ratio = match &ctrl.layout {
GroupLayout::Split { ratio, .. } => *ratio,
_ => panic!("expected Split"),
};
assert!((ratio - 0.3).abs() < 1e-6, "ratio={ratio}");
}
#[test]
fn from_layout_add_pane_after_construction() {
let panes = vec![
Pane::new("p0", vec![tab("t0", "a.rs", true)], "t0"),
Pane::new("p1", vec![tab("t1", "b.rs", true)], "t1"),
];
let layout = GroupLayout::Split {
direction: SplitDirection::Horizontal,
ratio: 0.5,
first: Box::new(GroupLayout::Leaf(0)),
second: Box::new(GroupLayout::Leaf(1)),
};
let mut ctrl = TabGroupController::from_layout(panes, layout, SplitDirection::Horizontal)
.expect("valid");
assert_eq!(ctrl.pane_count(), 2);
let new_idx = ctrl.add_pane_with_tab("extra", tab("te", "extra.rs", false));
assert_eq!(ctrl.pane_count(), 3);
assert_eq!(ctrl.layout.leaf_count(), 3);
assert_eq!(ctrl.panes[new_idx].id, "extra");
}
#[test]
fn from_layout_error_empty_panes() {
let result = TabGroupController::from_layout(
vec![],
GroupLayout::Leaf(0),
SplitDirection::Horizontal,
);
assert!(result.is_err(), "expected Err for empty panes");
let msg = result.err().unwrap();
assert!(msg.contains("non-empty"), "unexpected message: {msg}");
}
#[test]
fn from_layout_error_leaf_count_mismatch() {
let panes = vec![Pane::new("p0", vec![tab("t0", "a.rs", false)], "t0")];
let layout = GroupLayout::Split {
direction: SplitDirection::Horizontal,
ratio: 0.5,
first: Box::new(GroupLayout::Leaf(0)),
second: Box::new(GroupLayout::Leaf(1)),
};
let result = TabGroupController::from_layout(panes, layout, SplitDirection::Horizontal);
assert!(result.is_err(), "expected Err for count mismatch");
let msg = result.err().unwrap();
assert!(
msg.contains("2") && msg.contains("1"),
"error should mention counts, got: {msg}"
);
}
#[test]
fn from_layout_error_duplicate_leaf_index() {
let panes = vec![
Pane::new("p0", vec![tab("t0", "a.rs", false)], "t0"),
Pane::new("p1", vec![tab("t1", "b.rs", false)], "t1"),
];
let layout = GroupLayout::Split {
direction: SplitDirection::Horizontal,
ratio: 0.5,
first: Box::new(GroupLayout::Leaf(0)),
second: Box::new(GroupLayout::Leaf(0)), };
let result = TabGroupController::from_layout(panes, layout, SplitDirection::Horizontal);
assert!(result.is_err(), "expected Err for duplicate leaf");
let msg = result.err().unwrap();
assert!(
msg.contains("more than once") || msg.contains("duplicate"),
"unexpected error message: {msg}"
);
}
#[test]
fn from_layout_error_out_of_range_leaf_index() {
let panes = vec![
Pane::new("p0", vec![tab("t0", "a.rs", false)], "t0"),
Pane::new("p1", vec![tab("t1", "b.rs", false)], "t1"),
];
let layout = GroupLayout::Split {
direction: SplitDirection::Horizontal,
ratio: 0.5,
first: Box::new(GroupLayout::Leaf(0)),
second: Box::new(GroupLayout::Leaf(2)), };
let result = TabGroupController::from_layout(panes, layout, SplitDirection::Horizontal);
assert!(result.is_err(), "expected Err for out-of-range leaf");
let msg = result.err().unwrap();
assert!(
msg.contains("out of range") || msg.contains("range"),
"unexpected error message: {msg}"
);
}
#[test]
fn switch_tab_changes_active() {
let mut ctrl = make_group();
assert!(ctrl.switch_tab(0, "t1"));
assert_eq!(ctrl.panes[0].active_tab_id(), "t1");
}
#[test]
fn switch_tab_unknown_returns_false() {
let mut ctrl = make_group();
assert!(!ctrl.switch_tab(0, "no-such"));
assert_eq!(ctrl.panes[0].active_tab_id(), "t0"); }
#[test]
fn add_tab_appends_without_activating() {
let mut ctrl = make_group();
ctrl.add_tab(0, tab("t2", "new.rs", true));
assert_eq!(ctrl.panes[0].tabs().len(), 3);
assert_eq!(ctrl.panes[0].active_tab_id(), "t0"); }
#[test]
fn add_and_activate_tab_makes_it_active() {
let mut ctrl = make_group();
ctrl.add_and_activate_tab(0, tab("t2", "new.rs", true));
assert_eq!(ctrl.panes[0].active_tab_id(), "t2");
}
#[test]
fn close_inactive_tab_emits_tab_closed() {
let mut ctrl = make_group();
let ev = ctrl.close_tab(0, "t1");
assert_eq!(
ev,
Some(TabGroupEvent::TabClosed {
pane_idx: 0,
tab_id: "t1".into()
})
);
assert_eq!(ctrl.panes[0].tabs().len(), 1);
assert_eq!(ctrl.panes[0].active_tab_id(), "t0");
}
#[test]
fn close_active_tab_switches_to_previous() {
let mut ctrl = make_group();
ctrl.switch_tab(0, "t1"); let ev = ctrl.close_tab(0, "t1");
assert_eq!(
ev,
Some(TabGroupEvent::TabClosed {
pane_idx: 0,
tab_id: "t1".into()
})
);
assert_eq!(ctrl.panes[0].active_tab_id(), "t0"); }
#[test]
fn close_unknown_tab_returns_none() {
let mut ctrl = make_group();
assert_eq!(ctrl.close_tab(0, "ghost"), None);
}
#[test]
fn close_last_tab_in_solo_pane_does_not_collapse() {
let mut ctrl = TabGroupController::with_pane(
"p0",
vec![tab("t0", "only", true)],
"t0",
SplitDirection::Horizontal,
);
let ev = ctrl.close_tab(0, "t0");
assert_eq!(ev, Some(TabGroupEvent::PaneCollapsed { pane_idx: 0 }));
assert_eq!(ctrl.pane_count(), 1);
}
#[test]
fn close_last_tab_in_second_pane_collapses_it() {
let mut ctrl = make_group();
let idx = ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", true));
assert_eq!(ctrl.pane_count(), 2);
let ev = ctrl.close_tab(idx, "x0");
assert_eq!(ev, Some(TabGroupEvent::PaneCollapsed { pane_idx: idx }));
assert_eq!(ctrl.pane_count(), 1);
assert!(matches!(ctrl.layout, GroupLayout::Leaf(0)));
}
#[test]
fn add_pane_with_tab_creates_second_pane() {
let mut ctrl = make_group();
let idx = ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", true));
assert_eq!(idx, 1);
assert_eq!(ctrl.pane_count(), 2);
assert_eq!(ctrl.focused_pane(), Some(1));
assert!(matches!(ctrl.layout, GroupLayout::Split { .. }));
assert_eq!(ctrl.layout.leaf_count(), 2);
}
#[test]
fn three_panes_fractions_sum_to_one() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", false));
ctrl.add_pane_with_tab("p2", tab("y0", "y.rs", false));
assert_eq!(ctrl.pane_count(), 3);
assert_eq!(ctrl.layout.leaf_count(), 3);
}
#[test]
fn focus_pane_sets_active() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", false));
ctrl.focus_pane(0);
assert_eq!(ctrl.focused_pane(), Some(0));
}
#[test]
fn cycle_focus_wraps_around() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", false));
ctrl.focus_pane(0);
ctrl.cycle_focus(1); assert_eq!(ctrl.focused_pane(), Some(1));
ctrl.cycle_focus(1); assert_eq!(ctrl.focused_pane(), Some(0));
}
#[test]
fn click_inactive_tab_emits_tab_activated() {
let mut ctrl = make_group();
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
let ev = ctrl.handle_click(9.0, 0.5); assert_eq!(
ev,
Some(TabGroupEvent::TabActivated {
pane_idx: 0,
tab_id: "t1".into()
})
);
assert_eq!(ctrl.panes[0].active_tab_id(), "t1");
}
#[test]
fn click_active_tab_returns_none() {
let mut ctrl = make_group();
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
assert_eq!(ctrl.handle_click(3.0, 0.5), None);
}
#[test]
fn click_close_button_emits_tab_closed() {
let mut ctrl = make_group();
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
let ev = ctrl.handle_click(7.0, 0.5);
assert_eq!(
ev,
Some(TabGroupEvent::TabClosed {
pane_idx: 0,
tab_id: "t0".into()
})
);
assert_eq!(ctrl.panes[0].tabs().len(), 1);
}
#[test]
fn click_new_tab_button_emits_new_tab_requested() {
let mut ctrl = make_group();
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
let ev = ctrl.handle_click(78.0, 0.5);
assert_eq!(ev, Some(TabGroupEvent::NewTabRequested { pane_idx: 0 }));
}
#[test]
fn click_content_area_focuses_pane() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", false));
ctrl.focus_pane(0);
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
prime_pane(&mut ctrl, 1, 80.0, 0.0, 80.0, 8.0, 2.0);
let ev = ctrl.handle_click(85.0, 5.0);
assert_eq!(ev, Some(TabGroupEvent::PaneFocused { pane_idx: 1 }));
assert_eq!(ctrl.focused_pane(), Some(1));
}
#[test]
fn click_already_focused_content_returns_none() {
let mut ctrl = make_group();
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
ctrl.focus_pane(0);
assert_eq!(ctrl.handle_click(5.0, 5.0), None);
}
#[test]
fn click_with_offset_strip_resolves_correctly() {
let mut ctrl = make_group();
prime_pane(&mut ctrl, 0, 20.0, 5.0, 80.0, 8.0, 2.0);
let ev = ctrl.handle_click(30.0, 5.5);
assert_eq!(
ev,
Some(TabGroupEvent::TabActivated {
pane_idx: 0,
tab_id: "t1".into()
})
);
}
#[test]
fn drag_start_on_non_divider_returns_false() {
let mut ctrl = make_group();
assert!(!ctrl.handle_drag_start(50.0, 50.0));
}
#[test]
fn drag_start_on_divider_returns_true() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", false));
ctrl.last_dividers = vec![DividerInfo {
divider_bounds: Rect::new(40.0, 0.0, 1.0, 20.0),
direction: SplitDirection::Horizontal,
split_bounds: Rect::new(0.0, 0.0, 80.0, 20.0),
ratio: 0.5,
}];
assert!(ctrl.handle_drag_start(40.5, 10.0));
assert_eq!(ctrl.dragging_divider, Some(0));
}
#[test]
fn drag_move_updates_split_ratio() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", false));
ctrl.last_dividers = vec![DividerInfo {
divider_bounds: Rect::new(50.0, 0.0, 1.0, 20.0),
direction: SplitDirection::Horizontal,
split_bounds: Rect::new(0.0, 0.0, 100.0, 20.0),
ratio: 0.5,
}];
ctrl.handle_drag_start(50.5, 5.0);
let bounds = Rect::new(0.0, 0.0, 100.0, 20.0); let ev = ctrl.handle_drag_move(30.0, 5.0, bounds); assert_eq!(ev, Some(TabGroupEvent::DividerResized { divider_idx: 0 }));
let ratio = match &ctrl.layout {
GroupLayout::Split { ratio, .. } => *ratio,
_ => panic!("expected Split at root"),
};
assert!(
(ratio - 0.30).abs() < 0.02,
"ratio={ratio}, expected ≈ 0.30"
);
assert!((1.0 - ratio - 0.70).abs() < 0.02);
}
#[test]
fn drag_end_clears_drag_state() {
let mut ctrl = make_group();
ctrl.dragging_divider = Some(0);
ctrl.handle_drag_end();
assert_eq!(ctrl.dragging_divider, None);
}
#[test]
fn drop_group_rects_empty_before_render() {
let ctrl = make_group();
assert!(ctrl.drop_group_rects().is_empty());
}
#[test]
fn drop_group_rects_populated_after_prime() {
let mut ctrl = make_group();
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
let rects = ctrl.drop_group_rects();
assert_eq!(rects.len(), 1);
assert_eq!(rects[0].bounds.width, 80.0);
}
#[test]
fn collapse_pane_reduces_leaf_count() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", false));
ctrl.add_pane_with_tab("p2", tab("y0", "y.rs", false));
assert_eq!(ctrl.pane_count(), 3);
assert_eq!(ctrl.layout.leaf_count(), 3);
ctrl.collapse_pane(1);
assert_eq!(ctrl.pane_count(), 2);
assert_eq!(ctrl.layout.leaf_count(), 2);
}
#[test]
fn layout_after_two_adds_is_correct() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", false));
ctrl.add_pane_with_tab("p2", tab("y0", "y.rs", false));
assert!(ctrl.layout.contains_leaf(0));
assert!(ctrl.layout.contains_leaf(1));
assert!(ctrl.layout.contains_leaf(2));
assert_eq!(ctrl.layout.leaf_count(), 3);
}
#[test]
fn top_bottom_edge_drops_create_vertical_splits() {
let mut ctrl = make_group();
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", true));
ctrl.focus_pane(0);
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
prime_pane(&mut ctrl, 1, 80.0, 0.0, 80.0, 8.0, 2.0);
ctrl.handle_tab_drag_start(9.0, 0.5); let evs = ctrl.handle_tab_drop(120.0, 2.5);
if let Some(TabGroupEvent::TabSplitToNewPane { edge, .. }) = evs.first() {
assert!(
matches!(edge, DropEdge::Top),
"expected Top edge, got {edge:?}"
);
} else {
return;
}
fn find_any_vertical(node: &GroupLayout) -> bool {
match node {
GroupLayout::Leaf(_) => false,
GroupLayout::Split {
direction,
first,
second,
..
} => {
*direction == SplitDirection::Vertical
|| find_any_vertical(first)
|| find_any_vertical(second)
}
}
}
assert!(
find_any_vertical(&ctrl.layout),
"expected a Vertical split in tree after Top-edge drop"
);
}
fn make_two_pane() -> TabGroupController {
let mut ctrl = TabGroupController::with_pane(
"p0",
vec![tab("t0", "main.rs", true), tab("t1", "lib.rs", true)],
"t0",
SplitDirection::Horizontal,
);
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", true));
ctrl.focus_pane(0);
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
prime_pane(&mut ctrl, 1, 80.0, 0.0, 80.0, 8.0, 2.0);
ctrl
}
#[test]
fn tab_drag_start_on_tab_body_returns_true() {
let mut ctrl = make_two_pane();
assert!(ctrl.handle_tab_drag_start(9.0, 0.5));
assert!(ctrl.is_tab_dragging());
let drag = ctrl.dragging_tab.as_ref().unwrap();
assert_eq!(drag.source_pane_idx, 0);
assert_eq!(drag.tab_id, "t1");
}
#[test]
fn tab_drag_start_on_close_button_returns_false() {
let mut ctrl = make_two_pane();
assert!(!ctrl.handle_tab_drag_start(7.0, 0.5));
assert!(!ctrl.is_tab_dragging());
}
#[test]
fn tab_drag_start_on_new_tab_button_returns_false() {
let mut ctrl = make_two_pane();
assert!(!ctrl.handle_tab_drag_start(78.0, 0.5));
assert!(!ctrl.is_tab_dragging());
}
#[test]
fn tab_drag_start_in_content_area_returns_false() {
let mut ctrl = make_two_pane();
assert!(!ctrl.handle_tab_drag_start(40.0, 5.0));
assert!(!ctrl.is_tab_dragging());
}
#[test]
fn tab_drag_start_outside_all_panes_returns_false() {
let mut ctrl = make_two_pane();
assert!(!ctrl.handle_tab_drag_start(999.0, 0.5));
assert!(!ctrl.is_tab_dragging());
}
#[test]
fn tab_drag_move_returns_none_when_not_dragging() {
let mut ctrl = make_two_pane();
assert!(ctrl.handle_tab_drag_move(120.0, 5.0).is_none());
}
#[test]
fn tab_drag_move_returns_drop_zone_and_stores_it() {
let mut ctrl = make_two_pane();
assert!(ctrl.handle_tab_drag_start(9.0, 0.5));
let zone = ctrl.handle_tab_drag_move(120.0, 5.0);
assert_eq!(
zone,
Some(crate::DropZone {
kind: crate::DropZoneKind::Center,
group_idx: 1,
})
);
let stored = ctrl.dragging_tab.as_ref().unwrap().current_zone.as_ref();
assert_eq!(stored.unwrap().group_idx, 1);
}
#[test]
fn tab_drag_overlay_returns_none_without_zone() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
assert!(ctrl.tab_drag_overlay(9.0, 0.5, 2.0, 10.0).is_none());
}
#[test]
fn tab_drag_overlay_returns_highlight_after_move() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
ctrl.handle_tab_drag_move(120.0, 5.0);
let ov = ctrl.tab_drag_overlay(120.0, 5.0, 2.0, 10.0).unwrap();
assert_eq!(ov.highlight, Some(crate::Rect::new(80.0, 1.0, 80.0, 10.0)));
assert!(ov.insertion_bar.is_none());
}
#[test]
fn cancel_tab_drag_clears_state() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
assert!(ctrl.is_tab_dragging());
ctrl.cancel_tab_drag();
assert!(!ctrl.is_tab_dragging());
}
#[test]
fn drop_zone_at_center_of_pane_1() {
let ctrl = make_two_pane();
let zone = ctrl.drop_zone_at(120.0, 5.0).unwrap();
assert_eq!(zone.group_idx, 1);
assert_eq!(zone.kind, crate::DropZoneKind::Center);
}
#[test]
fn drop_zone_at_tab_bar_of_pane_1_reorder() {
let ctrl = make_two_pane();
let zone = ctrl.drop_zone_at(82.0, 0.5).unwrap();
assert_eq!(zone.group_idx, 1);
assert!(matches!(zone.kind, crate::DropZoneKind::TabReorder(_)));
}
#[test]
fn tab_drop_reorders_within_same_pane() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(3.0, 0.5);
assert_eq!(
evs,
vec![TabGroupEvent::TabReordered {
pane_idx: 0,
tab_id: "t1".into(),
from_idx: 1,
to_idx: 0,
}]
);
assert_eq!(ctrl.panes[0].tabs()[0].id, "t1");
assert_eq!(ctrl.panes[0].tabs()[1].id, "t0");
assert_eq!(ctrl.pane_count(), 2);
}
#[test]
fn tab_drop_reorder_same_position_is_noop() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(14.0, 0.5);
assert!(evs.is_empty());
assert_eq!(ctrl.panes[0].tabs()[0].id, "t0");
assert_eq!(ctrl.panes[0].tabs()[1].id, "t1");
}
#[test]
fn tab_drop_same_pane_center_is_noop() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(40.0, 5.0);
assert!(evs.is_empty());
assert_eq!(ctrl.pane_count(), 2);
assert_eq!(ctrl.panes[0].tabs().len(), 2);
}
#[test]
fn tab_drop_moves_tab_to_another_pane_center() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(120.0, 5.0);
assert_eq!(
evs,
vec![TabGroupEvent::TabMovedToPane {
from_pane_idx: 0,
to_pane_idx: 1,
tab_id: "t1".into(),
insert_idx: 1,
}]
);
assert_eq!(ctrl.pane_count(), 2);
assert_eq!(ctrl.panes[0].tabs().len(), 1);
assert_eq!(ctrl.panes[0].tabs()[0].id, "t0");
assert_eq!(ctrl.panes[1].tabs().len(), 2);
assert_eq!(ctrl.panes[1].tabs()[1].id, "t1");
assert_eq!(ctrl.panes[1].active_tab_id(), "t1");
assert_eq!(ctrl.layout.leaf_count(), 2);
}
#[test]
fn tab_drop_moves_tab_to_another_pane_tab_bar() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(82.0, 0.5);
assert_eq!(
evs,
vec![TabGroupEvent::TabMovedToPane {
from_pane_idx: 0,
to_pane_idx: 1,
tab_id: "t1".into(),
insert_idx: 0,
}]
);
assert_eq!(ctrl.panes[1].tabs()[0].id, "t1");
assert_eq!(ctrl.panes[1].tabs()[1].id, "x0");
}
#[test]
fn tab_drop_source_collapses_when_last_tab_moved() {
let mut ctrl = TabGroupController::with_pane(
"p0",
vec![tab("only", "only.rs", true)],
"only",
SplitDirection::Horizontal,
);
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", true));
ctrl.focus_pane(0);
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
prime_pane(&mut ctrl, 1, 80.0, 0.0, 80.0, 8.0, 2.0);
ctrl.handle_tab_drag_start(3.0, 0.5);
let evs = ctrl.handle_tab_drop(120.0, 5.0);
assert_eq!(ctrl.pane_count(), 1);
assert_eq!(
evs,
vec![
TabGroupEvent::TabMovedToPane {
from_pane_idx: 0,
to_pane_idx: 0,
tab_id: "only".into(),
insert_idx: 1,
},
TabGroupEvent::PaneCollapsed { pane_idx: 0 },
]
);
assert_eq!(ctrl.panes[0].tabs().len(), 2);
assert_eq!(ctrl.panes[0].tabs()[1].id, "only");
assert_eq!(ctrl.layout.leaf_count(), 1);
}
#[test]
fn tab_drop_splits_right_edge_creates_pane_after_target() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(150.0, 5.0);
assert_eq!(evs.len(), 1, "no collapse expected: {evs:?}");
assert!(
matches!(
&evs[0],
TabGroupEvent::TabSplitToNewPane {
from_pane_idx: 0,
tab_id,
target_pane_idx: 1,
edge: crate::DropEdge::Right,
new_pane_idx: 2,
} if tab_id == "t1"
),
"unexpected event: {:?}",
evs[0]
);
assert_eq!(ctrl.pane_count(), 3);
assert_eq!(ctrl.panes[0].tabs()[0].id, "t0");
assert_eq!(ctrl.panes[1].tabs()[0].id, "x0");
assert_eq!(ctrl.panes[2].tabs()[0].id, "t1");
assert_eq!(ctrl.layout.leaf_count(), 3);
}
#[test]
fn tab_drop_splits_left_edge_creates_pane_before_target() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(83.0, 5.0);
assert_eq!(evs.len(), 1, "no collapse expected: {evs:?}");
assert!(
matches!(
&evs[0],
TabGroupEvent::TabSplitToNewPane {
from_pane_idx: 0,
tab_id,
target_pane_idx: 1,
edge: crate::DropEdge::Left,
new_pane_idx: 1,
} if tab_id == "t1"
),
"unexpected event: {:?}",
evs[0]
);
assert_eq!(ctrl.pane_count(), 3);
assert_eq!(ctrl.panes[0].tabs()[0].id, "t0");
assert_eq!(ctrl.panes[1].tabs()[0].id, "t1");
assert_eq!(ctrl.panes[2].tabs()[0].id, "x0");
}
#[test]
fn tab_drop_split_source_collapses_when_last_tab() {
let mut ctrl = TabGroupController::with_pane(
"p0",
vec![tab("only", "only.rs", true)],
"only",
SplitDirection::Horizontal,
);
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", true));
ctrl.focus_pane(0);
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
prime_pane(&mut ctrl, 1, 80.0, 0.0, 80.0, 8.0, 2.0);
ctrl.handle_tab_drag_start(3.0, 0.5);
let evs = ctrl.handle_tab_drop(150.0, 5.0);
assert_eq!(ctrl.pane_count(), 2);
assert_eq!(evs.len(), 2, "expected split + collapse: {evs:?}");
assert!(
matches!(
&evs[0],
TabGroupEvent::TabSplitToNewPane {
from_pane_idx: 0,
tab_id,
new_pane_idx: 1,
..
} if tab_id == "only"
),
"unexpected primary event: {:?}",
evs[0]
);
assert_eq!(evs[1], TabGroupEvent::PaneCollapsed { pane_idx: 0 });
assert_eq!(ctrl.panes[0].tabs()[0].id, "x0");
assert_eq!(ctrl.panes[1].tabs()[0].id, "only");
assert_eq!(ctrl.layout.leaf_count(), 2);
}
#[test]
fn tab_drop_only_tab_only_pane_split_is_noop() {
let mut ctrl = TabGroupController::with_pane(
"p0",
vec![tab("t0", "only.rs", true)],
"t0",
SplitDirection::Horizontal,
);
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
ctrl.handle_tab_drag_start(3.0, 0.5);
let evs = ctrl.handle_tab_drop(70.0, 5.0);
assert!(evs.is_empty());
assert_eq!(ctrl.pane_count(), 1);
assert_eq!(ctrl.panes[0].tabs().len(), 1);
}
#[test]
fn tab_drop_clears_drag_state_on_noop() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(999.0, 999.0);
assert!(evs.is_empty());
assert!(!ctrl.is_tab_dragging());
}
#[test]
fn tab_drop_self_pane_edge_with_multiple_tabs_splits_without_collapse() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5); let evs = ctrl.handle_tab_drop(1.0, 5.0);
assert_eq!(evs.len(), 1, "no collapse expected: {evs:?}");
assert!(
matches!(
&evs[0],
TabGroupEvent::TabSplitToNewPane {
from_pane_idx: 0,
tab_id,
target_pane_idx: 0,
edge: crate::DropEdge::Left,
new_pane_idx: 0,
} if tab_id == "t1"
),
"unexpected event: {:?}",
evs[0]
);
assert_eq!(ctrl.pane_count(), 3);
assert_eq!(ctrl.layout.leaf_count(), 3);
assert_eq!(ctrl.panes[0].tabs().len(), 1);
assert_eq!(ctrl.panes[0].tabs()[0].id, "t1");
assert_eq!(ctrl.panes[1].tabs().len(), 1);
assert_eq!(ctrl.panes[1].tabs()[0].id, "t0");
assert_eq!(ctrl.panes[2].tabs()[0].id, "x0");
}
fn two_pane_drag_rects() -> (TabGroupController, Vec<PaneDragRect>) {
let mut ctrl = TabGroupController::with_pane(
"p0",
vec![tab("t0", "main.rs", true), tab("t1", "lib.rs", true)],
"t0",
SplitDirection::Horizontal,
);
ctrl.add_pane_with_tab("p1", tab("x0", "x.rs", true));
ctrl.focus_pane(0);
let rects = vec![
PaneDragRect {
strip_bounds: Rect::new(0.0, 0.0, 80.0, 1.0),
content_bounds: Rect::new(0.0, 1.0, 80.0, 10.0),
tab_slots: vec![(0.0, 8.0), (8.0, 16.0)],
},
PaneDragRect {
strip_bounds: Rect::new(80.0, 0.0, 80.0, 1.0),
content_bounds: Rect::new(80.0, 1.0, 80.0, 10.0),
tab_slots: vec![(80.0, 88.0)],
},
];
(ctrl, rects)
}
#[test]
fn set_drag_geometry_populates_drop_group_rects() {
let (mut ctrl, rects) = two_pane_drag_rects();
assert!(ctrl.drop_group_rects().is_empty());
ctrl.set_drag_geometry(&rects);
let groups = ctrl.drop_group_rects();
assert_eq!(groups.len(), 2, "both panes should be primed");
assert_eq!(groups[0].bounds.x, 0.0);
assert_eq!(groups[0].bounds.width, 80.0);
assert_eq!(groups[0].bounds.height, 11.0);
assert_eq!(groups[0].tab_slots.len(), 2);
assert_eq!(groups[0].tab_slots[0], (0.0, 8.0));
assert_eq!(groups[1].bounds.x, 80.0);
assert_eq!(groups[1].tab_slots.len(), 1);
}
#[test]
fn set_drag_geometry_enables_drop_zone_at() {
let (mut ctrl, rects) = two_pane_drag_rects();
ctrl.set_drag_geometry(&rects);
let zone = ctrl.drop_zone_at(120.0, 5.0).expect("should resolve");
assert_eq!(zone.group_idx, 1);
assert!(matches!(
zone.kind,
crate::primitives::drop_zone::DropZoneKind::Center
));
}
#[test]
fn set_drag_geometry_enables_handle_tab_drag_start() {
let (mut ctrl, rects) = two_pane_drag_rects();
ctrl.set_drag_geometry(&rects);
assert!(ctrl.handle_tab_drag_start(9.0, 0.5));
assert!(ctrl.is_tab_dragging());
let drag = ctrl.dragging_tab.as_ref().unwrap();
assert_eq!(drag.source_pane_idx, 0);
assert_eq!(drag.tab_id, "t1");
}
#[test]
fn set_drag_geometry_enables_handle_tab_drag_move() {
let (mut ctrl, rects) = two_pane_drag_rects();
ctrl.set_drag_geometry(&rects);
ctrl.handle_tab_drag_start(9.0, 0.5);
let zone = ctrl.handle_tab_drag_move(120.0, 5.0);
assert!(zone.is_some(), "should resolve a drop zone");
assert_eq!(zone.unwrap().group_idx, 1);
}
#[test]
fn set_drag_geometry_reorder_within_pane() {
let (mut ctrl, rects) = two_pane_drag_rects();
ctrl.set_drag_geometry(&rects);
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(3.0, 0.5);
assert_eq!(
evs,
vec![TabGroupEvent::TabReordered {
pane_idx: 0,
tab_id: "t1".into(),
from_idx: 1,
to_idx: 0,
}]
);
assert_eq!(ctrl.panes[0].tabs()[0].id, "t1");
assert_eq!(ctrl.panes[0].tabs()[1].id, "t0");
}
#[test]
fn set_drag_geometry_move_tab_to_other_pane() {
let (mut ctrl, rects) = two_pane_drag_rects();
ctrl.set_drag_geometry(&rects);
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(120.0, 5.0);
assert_eq!(evs.len(), 1, "no collapse expected: {evs:?}");
assert!(
matches!(
&evs[0],
TabGroupEvent::TabMovedToPane {
from_pane_idx: 0,
to_pane_idx: 1,
tab_id,
..
} if tab_id == "t1"
),
"unexpected event: {:?}",
evs[0]
);
assert_eq!(ctrl.pane_count(), 2);
assert_eq!(ctrl.panes[0].tabs().len(), 1);
assert_eq!(ctrl.panes[1].tabs().len(), 2);
}
#[test]
fn set_drag_geometry_split_to_new_pane() {
let (mut ctrl, rects) = two_pane_drag_rects();
ctrl.set_drag_geometry(&rects);
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(150.0, 5.0);
assert_eq!(evs.len(), 1, "no collapse: {evs:?}");
assert!(
matches!(
&evs[0],
TabGroupEvent::TabSplitToNewPane {
from_pane_idx: 0,
tab_id,
edge: crate::DropEdge::Right,
..
} if tab_id == "t1"
),
"unexpected event: {:?}",
evs[0]
);
assert_eq!(ctrl.pane_count(), 3);
assert_eq!(ctrl.layout.leaf_count(), 3);
}
#[test]
fn set_drag_geometry_ignores_excess_entries() {
let (mut ctrl, mut rects) = two_pane_drag_rects();
rects.push(PaneDragRect {
strip_bounds: Rect::new(200.0, 0.0, 80.0, 1.0),
content_bounds: Rect::new(200.0, 1.0, 80.0, 10.0),
tab_slots: vec![(200.0, 208.0)],
});
ctrl.set_drag_geometry(&rects);
assert_eq!(ctrl.drop_group_rects().len(), 2);
}
#[test]
fn set_drag_geometry_partial_prime_leaves_rest_unprimed() {
let (mut ctrl, rects) = two_pane_drag_rects();
ctrl.set_drag_geometry(&rects[..1]); let groups = ctrl.drop_group_rects();
assert_eq!(groups.len(), 1, "only pane 0 should be primed");
assert_eq!(groups[0].bounds.x, 0.0);
}
#[test]
fn split_then_collapse_then_split_unique_ids() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5); ctrl.handle_tab_drop(150.0, 5.0); assert_eq!(ctrl.pane_count(), 3);
let first_new_id = ctrl.panes[2].id.clone();
ctrl.collapse_pane(2); assert_eq!(ctrl.pane_count(), 2);
ctrl.add_tab(0, tab("t-extra", "extra.rs", false));
prime_pane(&mut ctrl, 0, 0.0, 0.0, 80.0, 8.0, 2.0);
prime_pane(&mut ctrl, 1, 80.0, 0.0, 80.0, 8.0, 2.0);
ctrl.handle_tab_drag_start(9.0, 0.5);
let evs = ctrl.handle_tab_drop(150.0, 5.0);
assert_eq!(
ctrl.pane_count(),
3,
"second split should create 3 panes: {evs:?}"
);
let second_new_id = ctrl.panes[2].id.clone();
assert_ne!(
second_new_id, first_new_id,
"second split pane must have a unique id (got {second_new_id:?})"
);
}
fn two_group_rects() -> Vec<DropGroupRect> {
vec![
DropGroupRect {
bounds: Rect::new(0.0, 0.0, 80.0, 11.0),
tab_slots: vec![(0.0, 8.0), (8.0, 16.0)],
},
DropGroupRect {
bounds: Rect::new(80.0, 0.0, 80.0, 11.0),
tab_slots: vec![(80.0, 88.0)],
},
]
}
#[test]
fn resolve_tab_drop_reorders_within_same_pane() {
let groups = two_group_rects();
let counts = [2usize, 1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 1, };
let instr = resolve_tab_drop(source, &counts, 3.0, 0.5, &groups, 1.0);
assert_eq!(
instr,
TabDropInstruction::Reorder {
pane_idx: 0,
from_idx: 1,
to_idx: 0,
}
);
}
#[test]
fn resolve_tab_drop_same_position_is_noop() {
let groups = two_group_rects();
let counts = [2usize, 1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 1,
};
let instr = resolve_tab_drop(source, &counts, 14.0, 0.5, &groups, 1.0);
assert_eq!(instr, TabDropInstruction::NoOp);
}
#[test]
fn resolve_tab_drop_same_pane_center_is_noop() {
let groups = two_group_rects();
let counts = [2usize, 1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 1,
};
let instr = resolve_tab_drop(source, &counts, 40.0, 5.0, &groups, 1.0);
assert_eq!(instr, TabDropInstruction::NoOp);
}
#[test]
fn resolve_tab_drop_moves_tab_to_another_pane_center() {
let groups = two_group_rects();
let counts = [2usize, 1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 1,
};
let instr = resolve_tab_drop(source, &counts, 120.0, 5.0, &groups, 1.0);
assert_eq!(
instr,
TabDropInstruction::MoveToPane {
from_pane_idx: 0,
from_idx: 1,
to_pane_idx: 1,
insert_idx: 1,
source_now_empty: false,
}
);
}
#[test]
fn resolve_tab_drop_reports_source_now_empty_when_last_tab_moves() {
let groups = two_group_rects();
let counts = [1usize, 1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 0,
};
let instr = resolve_tab_drop(source, &counts, 120.0, 5.0, &groups, 1.0);
assert_eq!(
instr,
TabDropInstruction::MoveToPane {
from_pane_idx: 0,
from_idx: 0,
to_pane_idx: 1,
insert_idx: 1,
source_now_empty: true,
}
);
}
#[test]
fn resolve_tab_drop_splits_right_edge() {
let groups = two_group_rects();
let counts = [2usize, 1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 1,
};
let instr = resolve_tab_drop(source, &counts, 150.0, 5.0, &groups, 1.0);
assert_eq!(
instr,
TabDropInstruction::SplitToNewPane {
from_pane_idx: 0,
from_idx: 1,
target_pane_idx: 1,
edge: DropEdge::Right,
split_direction: SplitDirection::Horizontal,
source_now_empty: false,
}
);
}
#[test]
fn resolve_tab_drop_only_tab_only_pane_split_is_noop() {
let groups = vec![DropGroupRect {
bounds: Rect::new(0.0, 0.0, 80.0, 11.0),
tab_slots: vec![(0.0, 8.0)],
}];
let counts = [1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 0,
};
let instr = resolve_tab_drop(source, &counts, 1.0, 5.0, &groups, 1.0);
assert_eq!(instr, TabDropInstruction::NoOp);
}
#[test]
fn resolve_tab_drop_outside_all_groups_is_noop() {
let groups = two_group_rects();
let counts = [2usize, 1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 1,
};
let instr = resolve_tab_drop(source, &counts, 999.0, 999.0, &groups, 1.0);
assert_eq!(instr, TabDropInstruction::NoOp);
}
#[test]
fn resolve_tab_drop_agrees_with_controller_handle_tab_drop() {
let mut ctrl = make_two_pane();
ctrl.handle_tab_drag_start(9.0, 0.5); let events = ctrl.handle_tab_drop(150.0, 5.0);
let groups = two_group_rects();
let counts = [2usize, 1usize];
let source = TabDragSource {
pane_idx: 0,
tab_idx: 1,
};
let instr = resolve_tab_drop(source, &counts, 150.0, 5.0, &groups, 1.0);
assert_eq!(
instr,
TabDropInstruction::SplitToNewPane {
from_pane_idx: 0,
from_idx: 1,
target_pane_idx: 1,
edge: DropEdge::Right,
split_direction: SplitDirection::Horizontal,
source_now_empty: false,
}
);
assert_eq!(
events,
vec![TabGroupEvent::TabSplitToNewPane {
from_pane_idx: 0,
tab_id: "t1".into(),
target_pane_idx: 1,
edge: DropEdge::Right,
new_pane_idx: 2,
}]
);
}
#[test]
fn strip_height_uses_chrome_line_height() {
let mut ctrl = make_group();
let mut backend = crate::testing::RecordingBackend {
line_height: 20.0,
chrome_line_height: Some(35.0),
viewport: crate::Viewport {
width: 800.0,
height: 600.0,
scale: 1.0,
},
char_width: 8.0,
..Default::default()
};
let bounds = Rect::new(0.0, 0.0, 800.0, 600.0);
let layout = ctrl.render(&mut backend, bounds);
assert_eq!(layout.strip_bounds.len(), 1);
assert_eq!(layout.strip_bounds[0].height, 35.0);
assert_eq!(layout.content_bounds[0].y, 35.0);
assert_eq!(layout.content_bounds[0].height, 600.0 - 35.0);
}
}