use crate::event::{Key, MouseButton, NamedKey, Rect};
#[allow(deprecated)]
use crate::primitives::tab_bar::{TabBar, TabBarHits, TabItem};
use crate::text_util::display_width;
use crate::types::{Modifiers, WidgetId};
use crate::Backend;
const CLOSE_COLS_ESTIMATE: usize = 2;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceDoc {
pub id: String,
pub label: String,
pub closable: bool,
pub dirty: bool,
}
impl WorkspaceDoc {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
closable: true,
dirty: false,
}
}
pub fn with_closable(mut self, closable: bool) -> Self {
self.closable = closable;
self
}
pub fn with_dirty(mut self, dirty: bool) -> Self {
self.dirty = dirty;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceEvent {
Opened {
id: String,
index: usize,
},
Activated {
id: String,
index: usize,
},
Closed {
id: String,
index: usize,
},
Reordered {
id: String,
from: usize,
to: usize,
},
Promoted {
id: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WorkspaceLayout {
pub strip_bounds: Rect,
pub body_bounds: Rect,
}
#[derive(Debug, Clone)]
pub struct WorkspaceController {
bar_id: WidgetId,
docs: Vec<WorkspaceDoc>,
active: Option<usize>,
preview: Option<String>,
scroll_offset: usize,
last_strip: Option<Rect>,
#[allow(deprecated)] last_hits: Option<TabBarHits>,
}
impl WorkspaceController {
pub fn new(bar_id: impl Into<String>) -> Self {
Self {
bar_id: WidgetId::new(bar_id),
docs: Vec::new(),
active: None,
preview: None,
scroll_offset: 0,
last_strip: None,
last_hits: None,
}
}
pub fn bar_id(&self) -> &WidgetId {
&self.bar_id
}
pub fn docs(&self) -> &[WorkspaceDoc] {
&self.docs
}
pub fn len(&self) -> usize {
self.docs.len()
}
pub fn is_empty(&self) -> bool {
self.docs.is_empty()
}
pub fn active_index(&self) -> Option<usize> {
self.active
}
pub fn active_id(&self) -> Option<&str> {
self.active.map(|i| self.docs[i].id.as_str())
}
pub fn active_doc(&self) -> Option<&WorkspaceDoc> {
self.active.map(|i| &self.docs[i])
}
pub fn index_of(&self, id: &str) -> Option<usize> {
self.docs.iter().position(|d| d.id == id)
}
pub fn contains(&self, id: &str) -> bool {
self.index_of(id).is_some()
}
pub fn is_preview(&self, id: &str) -> bool {
self.preview.as_deref() == Some(id)
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn open(&mut self, doc: WorkspaceDoc) -> Vec<WorkspaceEvent> {
if let Some(existing) = self.index_of(&doc.id) {
let mut events: Vec<WorkspaceEvent> =
self.set_active_index(existing).into_iter().collect();
let id = self.docs[existing].id.clone();
events.extend(self.promote(&id));
return events;
}
let index = self.docs.len();
let id = doc.id.clone();
self.docs.push(doc);
self.active = Some(index);
vec![WorkspaceEvent::Opened { id, index }]
}
pub fn open_preview(&mut self, doc: WorkspaceDoc) -> Vec<WorkspaceEvent> {
if let Some(existing) = self.index_of(&doc.id) {
return self.set_active_index(existing).into_iter().collect();
}
if let Some(preview_id) = self.preview.clone() {
if let Some(index) = self.index_of(&preview_id) {
let new_id = doc.id.clone();
self.docs[index] = doc;
self.preview = Some(new_id.clone());
self.active = Some(index);
self.clamp_scroll_offset();
return vec![
WorkspaceEvent::Closed {
id: preview_id,
index,
},
WorkspaceEvent::Opened { id: new_id, index },
];
}
}
let index = self.docs.len();
let id = doc.id.clone();
self.docs.push(doc);
self.active = Some(index);
self.preview = Some(id.clone());
vec![WorkspaceEvent::Opened { id, index }]
}
pub fn promote(&mut self, id: &str) -> Option<WorkspaceEvent> {
if !self.is_preview(id) {
return None;
}
self.preview = None;
Some(WorkspaceEvent::Promoted { id: id.to_string() })
}
pub fn activate(&mut self, id: &str) -> Vec<WorkspaceEvent> {
match self.index_of(id) {
Some(index) => self.activate_index(index),
None => Vec::new(),
}
}
pub fn activate_index(&mut self, index: usize) -> Vec<WorkspaceEvent> {
let mut events: Vec<WorkspaceEvent> = self.set_active_index(index).into_iter().collect();
if let Some(doc) = self.docs.get(index) {
let id = doc.id.clone();
events.extend(self.promote(&id));
}
events
}
fn set_active_index(&mut self, index: usize) -> Option<WorkspaceEvent> {
if index >= self.docs.len() || self.active == Some(index) {
return None;
}
self.active = Some(index);
Some(WorkspaceEvent::Activated {
id: self.docs[index].id.clone(),
index,
})
}
pub fn close(&mut self, id: &str) -> Vec<WorkspaceEvent> {
let Some(index) = self.index_of(id) else {
return Vec::new();
};
let removed = self.docs.remove(index);
if self.preview.as_deref() == Some(removed.id.as_str()) {
self.preview = None;
}
let mut events = vec![WorkspaceEvent::Closed {
id: removed.id,
index,
}];
let previous_active = self.active;
self.active = match previous_active {
_ if self.docs.is_empty() => None,
Some(active) if active == index => Some(index.min(self.docs.len() - 1)),
Some(active) if active > index => Some(active - 1),
other => other,
};
let closed_the_active_doc = previous_active == Some(index);
if closed_the_active_doc {
if let Some(new_active) = self.active {
events.push(WorkspaceEvent::Activated {
id: self.docs[new_active].id.clone(),
index: new_active,
});
}
}
self.clamp_scroll_offset();
events
}
pub fn reorder(&mut self, from: usize, to: usize) -> Option<WorkspaceEvent> {
if from >= self.docs.len() || to >= self.docs.len() || from == to {
return None;
}
let active_id = self.active_id().map(str::to_string);
let doc = self.docs.remove(from);
let id = doc.id.clone();
self.docs.insert(to, doc);
self.active = active_id.and_then(|id| self.index_of(&id));
self.clamp_scroll_offset();
Some(WorkspaceEvent::Reordered { id, from, to })
}
pub fn cycle(&mut self, delta: isize) -> Option<WorkspaceEvent> {
let n = self.docs.len();
if n == 0 {
return None;
}
let active = self.active.unwrap_or(0) as isize;
let n_i = n as isize;
let next = (active + delta).rem_euclid(n_i) as usize;
self.set_active_index(next)
}
pub fn set_label(&mut self, id: &str, label: impl Into<String>) -> bool {
match self.index_of(id) {
Some(i) => {
self.docs[i].label = label.into();
true
}
None => false,
}
}
pub fn set_dirty(&mut self, id: &str, dirty: bool) -> Vec<WorkspaceEvent> {
let Some(i) = self.index_of(id) else {
return Vec::new();
};
self.docs[i].dirty = dirty;
if dirty {
self.promote(id).into_iter().collect()
} else {
Vec::new()
}
}
pub fn tab_bar(&self) -> TabBar {
TabBar {
id: self.bar_id,
tabs: self
.docs
.iter()
.enumerate()
.map(|(i, d)| TabItem {
label: d.label.clone(),
is_active: self.active == Some(i),
is_dirty: d.dirty,
is_preview: self.is_preview(&d.id),
is_closable: d.closable,
})
.collect(),
scroll_offset: self.scroll_offset,
right_segments: Vec::new(),
active_accent: None,
show_tab_close: true,
compact: false,
}
}
#[allow(deprecated)] pub fn render(&mut self, backend: &mut dyn Backend, bounds: Rect) -> WorkspaceLayout {
let strip_height = backend.chrome_line_height().min(bounds.height.max(0.0));
let strip = Rect::new(bounds.x, bounds.y, bounds.width, strip_height);
let body = Rect::new(
bounds.x,
bounds.y + strip_height,
bounds.width,
(bounds.height - strip_height).max(0.0),
);
self.scroll_offset = self.estimate_scroll_offset(strip.width, backend.char_width());
let mut hits = backend.draw_tab_bar(strip, &self.tab_bar(), None);
if hits.correct_scroll_offset != self.scroll_offset {
self.scroll_offset = hits.correct_scroll_offset.min(self.max_scroll_offset());
hits = backend.draw_tab_bar(strip, &self.tab_bar(), None);
}
self.last_strip = Some(strip);
self.last_hits = Some(hits);
WorkspaceLayout {
strip_bounds: strip,
body_bounds: body,
}
}
pub fn strip_bounds(&self) -> Option<Rect> {
self.last_strip
}
#[allow(deprecated)] pub fn handle_click(&mut self, x: f32, y: f32, button: MouseButton) -> Vec<WorkspaceEvent> {
let (Some(strip), Some(hits)) = (self.last_strip, self.last_hits.as_ref()) else {
return Vec::new();
};
if y < strip.y || y >= strip.y + strip.height || x < strip.x || x >= strip.x + strip.width {
return Vec::new();
}
let click_x = x as f64;
let in_range = |(start, end): (f64, f64)| end > start && click_x >= start && click_x < end;
let body_hit = hits
.slot_positions
.iter()
.enumerate()
.find(|(_, range)| in_range(**range))
.map(|(i, _)| i);
if button == MouseButton::Middle {
let Some(i) = body_hit else {
return Vec::new();
};
let Some(doc) = self.docs.get(i) else {
return Vec::new();
};
if !doc.closable {
return Vec::new();
}
let id = doc.id.clone();
return self.close(&id);
}
let close_hit = hits
.close_bounds
.iter()
.enumerate()
.find(|(_, cb)| cb.is_some_and(in_range))
.map(|(i, _)| i);
if let Some(i) = close_hit {
let Some(doc) = self.docs.get(i) else {
return Vec::new();
};
if !doc.closable {
return Vec::new();
}
let id = doc.id.clone();
return self.close(&id);
}
match body_hit {
Some(i) => self.activate_index(i),
None => Vec::new(),
}
}
pub fn handle_key(&mut self, key: &Key, modifiers: Modifiers) -> Option<WorkspaceEvent> {
if !modifiers.ctrl {
return None;
}
let delta = match key {
Key::Named(NamedKey::BackTab) => -1,
Key::Named(NamedKey::Tab) if modifiers.shift => -1,
Key::Named(NamedKey::Tab) => 1,
Key::Named(NamedKey::PageUp) => -1,
Key::Named(NamedKey::PageDown) => 1,
_ => return None,
};
self.cycle(delta)
}
fn max_scroll_offset(&self) -> usize {
self.docs.len().saturating_sub(1)
}
fn clamp_scroll_offset(&mut self) {
self.scroll_offset = self.scroll_offset.min(self.max_scroll_offset());
}
fn estimate_scroll_offset(&self, strip_width: f32, char_width: f32) -> usize {
let Some(active) = self.active else {
return 0;
};
let cell = if char_width > 0.0 { char_width } else { 1.0 };
let available = (strip_width / cell).max(0.0) as usize;
TabBar::fit_active_scroll_offset(active, self.docs.len(), available, |i| {
let doc = &self.docs[i];
display_width(&doc.label) + if doc.closable { CLOSE_COLS_ESTIMATE } else { 0 }
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ws(ids: &[&str]) -> WorkspaceController {
let mut ws = WorkspaceController::new("test:workspace");
for id in ids {
ws.open(WorkspaceDoc::new(*id, *id));
}
ws
}
fn ids(ws: &WorkspaceController) -> Vec<&str> {
ws.docs().iter().map(|d| d.id.as_str()).collect()
}
#[test]
fn open_appends_and_activates() {
let mut ws = WorkspaceController::new("w");
assert!(ws.is_empty());
assert_eq!(ws.active_id(), None);
let ev = ws.open(WorkspaceDoc::new("a", "alpha"));
assert_eq!(
ev,
vec![WorkspaceEvent::Opened {
id: "a".into(),
index: 0
}]
);
let ev = ws.open(WorkspaceDoc::new("b", "beta"));
assert_eq!(
ev,
vec![WorkspaceEvent::Opened {
id: "b".into(),
index: 1
}]
);
assert_eq!(ids(&ws), ["a", "b"]);
assert_eq!(ws.active_id(), Some("b"));
assert_eq!(ws.len(), 2);
}
#[test]
fn reopening_an_open_id_activates_instead_of_duplicating() {
let mut ws = ws(&["a", "b", "c"]);
let ev = ws.open(WorkspaceDoc::new("a", "alpha (again)"));
assert_eq!(
ev,
vec![WorkspaceEvent::Activated {
id: "a".into(),
index: 0
}]
);
assert_eq!(ids(&ws), ["a", "b", "c"], "no duplicate tab");
assert_eq!(
ws.docs()[0].label,
"a",
"re-open must not silently relabel an existing document"
);
assert_eq!(ws.open(WorkspaceDoc::new("a", "alpha")), vec![]);
}
#[test]
fn activate_reports_only_real_changes() {
let mut ws = ws(&["a", "b"]);
assert_eq!(ws.active_id(), Some("b"));
assert_eq!(ws.activate("b"), vec![], "already active");
assert_eq!(ws.activate("nope"), vec![], "unknown id");
assert_eq!(
ws.activate("a"),
vec![WorkspaceEvent::Activated {
id: "a".into(),
index: 0
}]
);
}
#[test]
fn close_active_activates_right_neighbour() {
let mut ws = ws(&["a", "b", "c"]);
ws.activate("b");
let events = ws.close("b");
assert_eq!(
events,
vec![
WorkspaceEvent::Closed {
id: "b".into(),
index: 1
},
WorkspaceEvent::Activated {
id: "c".into(),
index: 1
},
],
"Closed must precede the Activated it triggers"
);
assert_eq!(ids(&ws), ["a", "c"]);
assert_eq!(ws.active_id(), Some("c"));
}
#[test]
fn close_last_active_activates_left_neighbour() {
let mut ws = ws(&["a", "b", "c"]);
assert_eq!(ws.active_id(), Some("c"));
let events = ws.close("c");
assert_eq!(
events,
vec![
WorkspaceEvent::Closed {
id: "c".into(),
index: 2
},
WorkspaceEvent::Activated {
id: "b".into(),
index: 1
},
]
);
assert_eq!(ws.active_id(), Some("b"));
}
#[test]
fn closing_a_non_active_document_keeps_the_active_one() {
let mut ws = ws(&["a", "b", "c"]);
ws.activate("c");
let events = ws.close("a");
assert_eq!(
events,
vec![WorkspaceEvent::Closed {
id: "a".into(),
index: 0
}]
);
assert_eq!(ws.active_id(), Some("c"));
assert_eq!(ws.active_index(), Some(1), "index tracked the shift");
let mut ws = ws2();
ws.activate("a");
let events = ws.close("c");
assert_eq!(
events,
vec![WorkspaceEvent::Closed {
id: "c".into(),
index: 2
}]
);
assert_eq!(ws.active_id(), Some("a"));
assert_eq!(ws.active_index(), Some(0));
}
fn ws2() -> WorkspaceController {
ws(&["a", "b", "c"])
}
#[test]
fn closing_the_last_document_empties_the_workspace_without_panicking() {
let mut ws = ws(&["only"]);
let events = ws.close("only");
assert_eq!(
events,
vec![WorkspaceEvent::Closed {
id: "only".into(),
index: 0
}],
"no Activated — there is nothing left to activate"
);
assert!(ws.is_empty());
assert_eq!(ws.active_id(), None);
assert_eq!(ws.active_index(), None);
assert_eq!(ws.cycle(1), None);
assert_eq!(ws.close("only"), vec![], "closing twice is a no-op");
assert!(ws.tab_bar().tabs.is_empty());
}
#[test]
fn close_unknown_id_is_a_noop() {
let mut ws = ws(&["a"]);
assert_eq!(ws.close("ghost"), vec![]);
assert_eq!(ws.len(), 1);
}
#[test]
fn cycle_wraps_in_both_directions() {
let mut ws = ws(&["a", "b", "c"]);
ws.activate("a");
assert_eq!(ws.cycle(1).map(event_id), Some("b".to_string()));
assert_eq!(ws.cycle(1).map(event_id), Some("c".to_string()));
assert_eq!(ws.cycle(1).map(event_id), Some("a".to_string()), "wraps");
assert_eq!(
ws.cycle(-1).map(event_id),
Some("c".to_string()),
"wraps backwards"
);
}
#[test]
fn cycle_on_a_single_document_is_a_noop() {
let mut ws = ws(&["a"]);
assert_eq!(ws.cycle(1), None);
assert_eq!(ws.active_id(), Some("a"));
}
fn event_id(ev: WorkspaceEvent) -> String {
match ev {
WorkspaceEvent::Opened { id, .. }
| WorkspaceEvent::Activated { id, .. }
| WorkspaceEvent::Closed { id, .. }
| WorkspaceEvent::Reordered { id, .. }
| WorkspaceEvent::Promoted { id } => id,
}
}
fn ctrl() -> Modifiers {
Modifiers {
ctrl: true,
..Modifiers::default()
}
}
fn ctrl_shift() -> Modifiers {
Modifiers {
ctrl: true,
shift: true,
..Modifiers::default()
}
}
#[test]
fn ctrl_tab_and_page_keys_cycle() {
let mut ws = ws(&["a", "b", "c"]);
ws.activate("a");
assert_eq!(
ws.handle_key(&Key::Named(NamedKey::Tab), ctrl())
.map(event_id),
Some("b".to_string())
);
assert_eq!(
ws.handle_key(&Key::Named(NamedKey::Tab), ctrl_shift())
.map(event_id),
Some("a".to_string())
);
assert_eq!(
ws.handle_key(&Key::Named(NamedKey::BackTab), ctrl())
.map(event_id),
Some("c".to_string()),
"crossterm's Ctrl+Shift+Tab spelling"
);
assert_eq!(
ws.handle_key(&Key::Named(NamedKey::PageDown), ctrl())
.map(event_id),
Some("a".to_string())
);
assert_eq!(
ws.handle_key(&Key::Named(NamedKey::PageUp), ctrl())
.map(event_id),
Some("c".to_string())
);
}
#[test]
fn unbound_keys_are_left_for_the_caller() {
let mut ws = ws(&["a", "b"]);
assert_eq!(
ws.handle_key(&Key::Named(NamedKey::Tab), Modifiers::default()),
None,
"plain Tab is not the workspace's key"
);
assert_eq!(ws.handle_key(&Key::Char('q'), ctrl()), None);
assert_eq!(
ws.handle_key(&Key::Named(NamedKey::Right), ctrl()),
None,
"arrows belong to whatever owns the body"
);
}
#[test]
fn reorder_moves_a_tab_and_preserves_the_active_document() {
let mut ws = ws(&["a", "b", "c"]);
ws.activate("a");
let ev = ws.reorder(0, 2);
assert_eq!(
ev,
Some(WorkspaceEvent::Reordered {
id: "a".into(),
from: 0,
to: 2
})
);
assert_eq!(ids(&ws), ["b", "c", "a"]);
assert_eq!(
ws.active_id(),
Some("a"),
"active is preserved by identity, not index"
);
assert_eq!(ws.active_index(), Some(2));
}
#[test]
fn reorder_rejects_out_of_range_and_noop_moves() {
let mut ws = ws(&["a", "b"]);
assert_eq!(ws.reorder(0, 0), None);
assert_eq!(ws.reorder(5, 0), None);
assert_eq!(ws.reorder(0, 5), None);
assert_eq!(ids(&ws), ["a", "b"]);
}
#[test]
fn tab_bar_marks_exactly_one_active_tab_and_carries_doc_flags() {
let mut ws = WorkspaceController::new("w");
ws.open(WorkspaceDoc::new("a", "alpha"));
ws.open(WorkspaceDoc::new("b", "beta").with_closable(false));
ws.set_dirty("a", true);
ws.activate("a");
let bar = ws.tab_bar();
assert_eq!(bar.id.as_str(), "w");
assert_eq!(bar.tabs.len(), 2);
assert_eq!(
bar.tabs.iter().filter(|t| t.is_active).count(),
1,
"open-N-view-one: exactly one active tab"
);
assert!(bar.tabs[0].is_active);
assert!(bar.tabs[0].is_dirty);
assert!(bar.tabs[0].is_closable);
assert!(!bar.tabs[1].is_closable);
assert_eq!(bar.tabs[1].label, "beta");
}
#[test]
fn set_label_reports_unknown_ids() {
let mut ws = ws(&["a"]);
assert!(ws.set_label("a", "renamed"));
assert!(!ws.set_label("ghost", "x"));
assert_eq!(ws.docs()[0].label, "renamed");
}
#[test]
fn set_dirty_sets_the_flag_regardless_of_preview_state() {
let mut ws = ws(&["a"]);
assert!(ws.contains("a"));
assert_eq!(ws.set_dirty("a", true), vec![]);
assert!(!ws.contains("ghost"));
assert_eq!(ws.set_dirty("ghost", true), vec![], "unknown id is a no-op");
assert!(ws.docs()[0].dirty);
}
#[test]
fn scroll_offset_estimate_keeps_the_active_tab_visible() {
let mut ws = WorkspaceController::new("w");
for i in 0..8 {
ws.open(WorkspaceDoc::new(format!("d{i}"), format!("docum{i}")));
}
ws.activate("d0");
assert_eq!(ws.estimate_scroll_offset(30.0, 1.0), 0);
ws.activate("d7");
let offset = ws.estimate_scroll_offset(30.0, 1.0);
assert!(
offset > 0 && offset <= 7,
"activating the last tab must scroll it into view, got {offset}"
);
assert!(
(7 - offset) * 8 <= 30,
"tabs from the resolved offset through the active one must fit \
in 30 columns, offset was {offset}"
);
ws.activate("d0");
assert_eq!(
ws.estimate_scroll_offset(30.0, 1.0),
0,
"activating the first tab scrolls back to the start"
);
}
#[test]
fn scroll_offset_estimate_is_zero_when_everything_fits() {
let mut ws = ws(&["a", "b", "c"]);
ws.activate("c");
assert_eq!(ws.estimate_scroll_offset(200.0, 1.0), 0);
}
#[test]
fn scroll_offset_estimate_survives_degenerate_geometry() {
let mut ws = ws(&["a", "b", "c"]);
ws.activate("c");
let fallback = ws.estimate_scroll_offset(0.0, 1.0);
assert_eq!(ws.estimate_scroll_offset(0.0, 0.0), fallback);
assert_eq!(ws.estimate_scroll_offset(-5.0, 1.0), fallback);
}
#[test]
fn click_before_the_first_render_is_ignored() {
let mut ws = ws(&["a", "b"]);
assert_eq!(
ws.handle_click(3.0, 0.0, MouseButton::Left),
vec![],
"no cached strip geometry yet"
);
}
#[cfg(feature = "tui")]
#[test]
fn close_cols_estimate_matches_tui_rasteriser() {
assert_eq!(CLOSE_COLS_ESTIMATE, crate::tui::TAB_CLOSE_COLS as usize);
}
#[test]
fn is_preview_is_false_for_unknown_and_permanent_docs() {
let ws = ws(&["a"]);
assert!(!ws.is_preview("a"), "a plain open() never sets preview");
assert!(!ws.is_preview("ghost"));
}
#[test]
fn open_preview_appends_a_flagged_tab_and_activates_it() {
let mut ws = WorkspaceController::new("w");
let events = ws.open_preview(WorkspaceDoc::new("a", "alpha"));
assert_eq!(
events,
vec![WorkspaceEvent::Opened {
id: "a".into(),
index: 0
}]
);
assert!(ws.is_preview("a"));
assert_eq!(ws.active_id(), Some("a"));
assert!(
ws.tab_bar().tabs[0].is_preview,
"must feed TabItem::is_preview"
);
}
#[test]
fn open_preview_of_a_second_doc_replaces_the_first_in_place() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
let events = ws.open_preview(WorkspaceDoc::new("b", "beta"));
assert_eq!(
events,
vec![
WorkspaceEvent::Closed {
id: "a".into(),
index: 0
},
WorkspaceEvent::Opened {
id: "b".into(),
index: 0
},
]
);
assert_eq!(ids(&ws), ["b"], "one tab, not two");
assert_eq!(ws.active_id(), Some("b"));
assert!(ws.is_preview("b"));
assert!(
!ws.is_preview("a"),
"a's slot was taken over, not preserved"
);
}
#[test]
fn promoting_then_opening_another_preview_yields_two_tabs() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
assert_eq!(
ws.promote("a"),
Some(WorkspaceEvent::Promoted { id: "a".into() })
);
let events = ws.open_preview(WorkspaceDoc::new("b", "beta"));
assert_eq!(
events,
vec![WorkspaceEvent::Opened {
id: "b".into(),
index: 1
}]
);
assert_eq!(ids(&ws), ["a", "b"]);
assert!(!ws.is_preview("a"), "a was promoted, so it keeps its slot");
assert!(ws.is_preview("b"));
}
#[test]
fn open_preview_of_an_already_permanent_doc_activates_without_touching_the_preview_slot() {
let mut ws = WorkspaceController::new("w");
ws.open(WorkspaceDoc::new("a", "alpha")); ws.open_preview(WorkspaceDoc::new("b", "beta")); assert_eq!(ws.active_id(), Some("b"));
let events = ws.open_preview(WorkspaceDoc::new("a", "alpha"));
assert_eq!(
events,
vec![WorkspaceEvent::Activated {
id: "a".into(),
index: 0
}]
);
assert_eq!(ids(&ws), ["a", "b"], "no new tab, no replace");
assert_eq!(ws.active_id(), Some("a"));
assert!(ws.is_preview("b"), "b's preview slot is untouched");
}
#[test]
fn open_preview_of_the_current_preview_reactivates_without_promoting() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
ws.activate_index(1_000_000); let events = ws.open_preview(WorkspaceDoc::new("a", "alpha again"));
assert_eq!(events, vec![], "already open and already active");
assert!(ws.is_preview("a"), "must stay preview");
assert_eq!(
ws.docs()[0].label,
"alpha",
"re-open via open_preview doesn't relabel either"
);
}
#[test]
fn promote_is_a_noop_for_unknown_or_non_preview_ids() {
let mut ws = ws(&["a"]);
assert_eq!(ws.promote("a"), None, "a is permanent, not preview");
assert_eq!(ws.promote("ghost"), None);
}
#[test]
fn promote_is_idempotent_emitting_the_event_exactly_once() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
assert_eq!(
ws.promote("a"),
Some(WorkspaceEvent::Promoted { id: "a".into() })
);
assert!(!ws.is_preview("a"));
assert_eq!(ws.promote("a"), None, "already permanent — no second event");
}
#[test]
fn closing_the_preview_tab_clears_the_preview_slot() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
ws.close("a");
ws.open(WorkspaceDoc::new("b", "beta"));
assert!(!ws.is_preview("b"));
}
#[test]
fn trigger_reopening_the_preview_as_permanent_promotes_it() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
assert!(ws.is_preview("a"));
let events = ws.open(WorkspaceDoc::new("a", "alpha"));
assert_eq!(events, vec![WorkspaceEvent::Promoted { id: "a".into() }]);
assert!(!ws.is_preview("a"));
}
#[test]
fn trigger_selecting_the_preview_tab_via_activate_promotes_it() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
ws.open(WorkspaceDoc::new("b", "beta")); assert!(ws.is_preview("a"));
let events = ws.activate("a");
assert_eq!(
events,
vec![
WorkspaceEvent::Activated {
id: "a".into(),
index: 0
},
WorkspaceEvent::Promoted { id: "a".into() },
],
"selecting the preview tab promotes it — the deliberate VS Code divergence"
);
assert!(!ws.is_preview("a"));
}
#[test]
fn trigger_clicking_the_preview_tab_promotes_it() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
ws.open(WorkspaceDoc::new("b", "beta"));
assert!(ws.is_preview("a"));
seed_click_geometry(
&mut ws,
Rect::new(0.0, 0.0, 80.0, 1.0),
&[(0.0, 10.0), (10.0, 20.0)],
);
let events = ws.handle_click(5.0, 0.5, MouseButton::Left);
assert!(
events.contains(&WorkspaceEvent::Promoted { id: "a".into() }),
"left click on the preview tab must promote it: {events:?}"
);
assert!(!ws.is_preview("a"));
}
#[test]
fn trigger_editing_the_preview_document_promotes_it() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
let events = ws.set_dirty("a", true);
assert_eq!(events, vec![WorkspaceEvent::Promoted { id: "a".into() }]);
assert!(!ws.is_preview("a"));
assert!(ws.docs()[0].dirty, "the dirty flag itself is still set");
}
#[test]
fn trigger_saving_promotes_the_preview_document() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
ws.set_dirty("a", true); assert!(!ws.is_preview("a"));
ws.open_preview(WorkspaceDoc::new("c", "gamma"));
ws.promote("c").expect("save handler promotes the preview");
assert!(!ws.is_preview("c"));
}
#[test]
fn trigger_explicit_pin_promotes_the_preview_document() {
let mut ws = WorkspaceController::new("w");
ws.open_preview(WorkspaceDoc::new("a", "alpha"));
assert!(ws.is_preview("a"));
let event = ws.promote("a");
assert_eq!(event, Some(WorkspaceEvent::Promoted { id: "a".into() }));
assert!(!ws.is_preview("a"));
}
#[test]
fn cycle_does_not_promote_even_when_it_lands_on_the_preview_tab() {
let mut ws = WorkspaceController::new("w");
ws.open(WorkspaceDoc::new("a", "alpha"));
ws.open_preview(WorkspaceDoc::new("b", "beta"));
ws.activate("a");
assert!(ws.is_preview("b"));
let ev = ws.cycle(1); assert_eq!(ev.map(event_id), Some("b".to_string()));
assert!(
ws.is_preview("b"),
"sequential cycling must not promote — only an explicit jump does"
);
}
#[test]
fn middle_click_closes_a_tab_left_click_activates() {
let mut ws = WorkspaceController::new("w");
ws.open(WorkspaceDoc::new("a", "alpha"));
ws.open(WorkspaceDoc::new("b", "beta"));
ws.activate("a");
seed_click_geometry(
&mut ws,
Rect::new(0.0, 0.0, 80.0, 1.0),
&[(0.0, 10.0), (10.0, 20.0)],
);
let events = ws.handle_click(15.0, 0.5, MouseButton::Middle);
assert_eq!(
events,
vec![WorkspaceEvent::Closed {
id: "b".into(),
index: 1
}],
"middle click closes, it does not activate first"
);
assert_eq!(ids(&ws), ["a"]);
assert_eq!(
ws.active_id(),
Some("a"),
"middle-closing a non-active tab must not disturb the active one"
);
}
#[test]
fn middle_click_respects_the_closable_flag() {
let mut ws = WorkspaceController::new("w");
ws.open(WorkspaceDoc::new("a", "alpha").with_closable(false));
seed_click_geometry(&mut ws, Rect::new(0.0, 0.0, 80.0, 1.0), &[(0.0, 10.0)]);
assert_eq!(ws.handle_click(5.0, 0.5, MouseButton::Middle), vec![]);
assert_eq!(ws.len(), 1, "non-closable tab survives a middle click");
}
#[allow(deprecated)] fn seed_click_geometry(ws: &mut WorkspaceController, bounds: Rect, tabs: &[(f64, f64)]) {
ws.last_strip = Some(bounds);
ws.last_hits = Some(TabBarHits {
slot_positions: tabs.to_vec(),
close_bounds: vec![None; tabs.len()],
right_segment_bounds: Vec::new(),
available_cols: bounds.width as usize,
correct_scroll_offset: 0,
});
}
}