quadraui/compose/workspace.rs
1//! `WorkspaceController` — an **open-N-view-one** set of opaque document
2//! ids with exactly one active, rendered through the [`TabBar`] primitive.
3//!
4//! # What it is
5//!
6//! An ordered `Vec` of *opaque* document ids plus one active id, with
7//! `open` / `close` / `activate` / `reorder`, a tab strip painted through
8//! [`Backend::draw_tab_bar`], and click + keyboard routing. Every state
9//! change is reported as a [`WorkspaceEvent`] so the host app can react
10//! (load a file, drop a buffer, persist the session) without inspecting
11//! controller internals.
12//!
13//! ```ignore
14//! use quadraui::compose::workspace::{WorkspaceController, WorkspaceDoc, WorkspaceEvent};
15//!
16//! let mut ws = WorkspaceController::new("panel:docs");
17//! ws.open(WorkspaceDoc::new("doc:a", "alpha.rs"));
18//! ws.open(WorkspaceDoc::new("doc:b", "beta.rs"));
19//!
20//! // In AppLogic::render — the controller paints its own strip into
21//! // whatever rect the host gives it (a panel's rect, a shell's content
22//! // area, a split pane — the controller does not care):
23//! let layout = ws.render(backend, panel_rect);
24//! // …host paints the active document's body into `layout.body_bounds`.
25//!
26//! // In AppLogic::handle:
27//! # let button = quadraui::MouseButton::Left;
28//! for ev in ws.handle_click(pos.x, pos.y, button) {
29//! if let WorkspaceEvent::Closed { id, .. } = ev { /* drop the buffer */ }
30//! }
31//! ```
32//!
33//! # How it differs from [`TabGroupController`]
34//!
35//! [`TabGroupController`](crate::compose::tab_group::TabGroupController)
36//! owns its content: `PaneTab::content` is a
37//! `Box<dyn BackendWidget>` (`Send + 'static`), which *cannot* hold a view
38//! that borrows the host's app state. `WorkspaceController` owns **no
39//! content at all** — a document is just an id plus a label, and the host
40//! paints the body itself into [`WorkspaceLayout::body_bounds`]. That is
41//! the deliberate difference, and it is why the two live side by side
42//! rather than one replacing the other:
43//!
44//! | | `TabGroupController` | `WorkspaceController` |
45//! |---|---|---|
46//! | Panes | N, in a split tree | 1 |
47//! | Content | owned `Box<dyn BackendWidget>` | host-painted, id only |
48//! | Body borrows app state | impossible (`'static`) | natural |
49//! | Split / drag-and-drop | yes | no |
50//!
51//! # Non-goals
52//!
53//! - **No persistence.** The host app owns session save/restore; the
54//! controller is pure in-memory state.
55//! - **No content ownership.** See above.
56//! - **No shell slot.** Mounting a document tab strip into `AppShell`'s
57//! chrome is #469's remaining half; this controller is deliberately
58//! mountable into *any* rect, which is what lets a consumer put one
59//! inside a single panel.
60//!
61//! # Close-neighbour rule
62//!
63//! Closing the **active** document activates the document that now
64//! occupies the closed one's index — i.e. its **right-hand neighbour**.
65//! When the closed document was the last in the strip there is no
66//! right-hand neighbour, so the new last document (its left-hand
67//! neighbour) becomes active. Closing the only document leaves the
68//! workspace empty and [`WorkspaceController::active_id`] returns `None`.
69//! Closing a **non-active** document never changes which document is
70//! active (the active index shifts to track it).
71//!
72//! This is VS Code's rule, and it is pinned by
73//! `close_active_activates_right_neighbour` /
74//! `close_last_active_activates_left_neighbour` below.
75//!
76//! # Keyboard
77//!
78//! [`WorkspaceController::handle_key`] consumes, all wrapping around the
79//! ends of the strip:
80//!
81//! | Key | Effect |
82//! |---|---|
83//! | `Ctrl+Tab` / `Ctrl+PageDown` | activate the next document |
84//! | `Ctrl+Shift+Tab` / `Ctrl+PageUp` | activate the previous document |
85//!
86//! # Overflow
87//!
88//! Overflow scrolling is the [`TabBar`] primitive's job
89//! ([`TabBar::fit_active_scroll_offset`]); the controller owns the
90//! resulting `scroll_offset` and recomputes it every frame so the active
91//! document's tab is visible on the *same* frame it becomes active — not
92//! one frame later. See [`WorkspaceController::render`] for the two-pass
93//! detail.
94//!
95//! # Preview tabs
96//!
97//! Ported from vimcode's working implementation (`preview_buffer_id`,
98//! `promote_preview`, the find-existing-else-replace `open_file_preview`)
99//! rather than designed fresh, because claude-coordinator needs the
100//! identical policy for its per-panel document tabs and the two apps are
101//! meant to stay in lock-step — see the issue for the "one implementation"
102//! rationale.
103//!
104//! At most **one** document per workspace is the preview — the
105//! replaceable slot VS Code shows italic and reuses for the next
106//! single-click navigation instead of accumulating a tab per click.
107//! [`Self::open_preview`] is the entry point:
108//!
109//! 1. `id` already open (permanent, or the current preview) → activate it.
110//! No new tab, no replace, no promotion.
111//! 2. Otherwise, a preview tab already exists → replace its contents **in
112//! that tab's slot** (same index), discarding the old preview.
113//! 3. Otherwise → append a new tab flagged preview.
114//!
115//! [`Self::promote`] drops the preview flag, turning it into an ordinary
116//! permanent tab, and is the one primitive every promotion trigger below
117//! reduces to.
118//!
119//! ## The six promotion triggers
120//!
121//! Ported verbatim — this is the behaviour to preserve, not to improve.
122//! Note the second row **deliberately diverges from VS Code** (which keeps
123//! a tab in preview when clicked in the tab bar): vimcode promotes on
124//! select, and that is the contract both consumers are written against.
125//!
126//! | Trigger | How | vimcode reference |
127//! |---|---|---|
128//! | Re-opening it as permanent (incl. a double click on the source row, which just calls the permanent open instead of the preview one) | [`Self::open`] promotes when `id` is already the preview | `engine/windows.rs:2297-2299`, `engine/tests.rs::test_double_click_promotes_preview` |
129//! | Selecting the preview tab | [`Self::activate`] / [`Self::activate_index`] / a left click on the tab body (via [`Self::handle_click`]) promote when the newly-selected tab is the preview | `engine/windows.rs:1757-1761` (`goto_tab`) |
130//! | Editing the document | [`Self::set_dirty`]`(id, true)` promotes when `id` is the preview | `engine/keys.rs:427-430` |
131//! | Saving | the host calls [`Self::promote`] from its save handler | `engine/tests.rs::test_save_promotes_preview` |
132//! | Explicit pin | the host calls [`Self::promote`] from a pin action | — |
133//!
134//! Sequential keyboard cycling ([`Self::cycle`] / `Ctrl+Tab`) deliberately
135//! does **not** promote — only an explicit jump to a specific tab
136//! (`activate`/`activate_index`/a click) does, matching `goto_tab` rather
137//! than vimcode's separate tab-cycling path.
138//!
139//! [`WorkspaceEvent::Promoted`] is emitted exactly once per promotion,
140//! whichever trigger caused it. [`Self::is_preview`] feeds
141//! [`TabItem::is_preview`], which every backend already renders italic
142//! (`quadraui/src/tui/tab_bar.rs`).
143
144use crate::event::{Key, MouseButton, NamedKey, Rect};
145// `TabBarHits` is `#[deprecated]` — this compose helper caches
146// exactly what `Backend::draw_tab_bar` returns for click dispatch. See
147// that struct's doc for the replacement plan.
148#[allow(deprecated)]
149use crate::primitives::tab_bar::{TabBar, TabBarHits, TabItem};
150use crate::text_util::display_width;
151use crate::types::{Modifiers, WidgetId};
152use crate::Backend;
153
154/// Columns the controller's portable pre-paint estimate reserves for a
155/// closable tab's close affordance: the `×` / `●` glyph plus the trailing
156/// separator cell.
157///
158/// This mirrors `quadraui::tui::TAB_CLOSE_COLS`, which cannot be named
159/// here — `compose` compiles with no backend feature enabled, and that
160/// constant lives behind `#[cfg(feature = "tui")]`. The two are pinned
161/// together by `close_cols_estimate_matches_tui_rasteriser` below, which
162/// runs whenever the `tui` feature is on.
163const CLOSE_COLS_ESTIMATE: usize = 2;
164
165/// One document in a [`WorkspaceController`].
166///
167/// `id` is **opaque** to quadraui: a path, a URI, a database key,
168/// whatever the host wants. The controller only ever compares ids for
169/// equality and hands them back in [`WorkspaceEvent`]s. `label` is what
170/// the tab paints.
171///
172/// `#[non_exhaustive]`: brand new, so marking it costs no consumer
173/// anything today and a later field (an icon, a preview flag) stays
174/// additive rather than breaking every literal — see `CLAUDE.md`'s
175/// *Downstream consumers* rule 2. Build with [`WorkspaceDoc::new`] plus
176/// the `with_*` setters.
177#[non_exhaustive]
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct WorkspaceDoc {
180 /// Opaque, host-defined identity. Unique within one controller.
181 pub id: String,
182 /// Text painted on the tab.
183 pub label: String,
184 /// When `false` the tab paints no close button and
185 /// [`WorkspaceController::handle_click`] cannot close it. Defaults to
186 /// `true`.
187 pub closable: bool,
188 /// Unsaved-changes marker — the tab paints `●` instead of `×`.
189 pub dirty: bool,
190}
191
192impl WorkspaceDoc {
193 /// A closable, clean document.
194 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
195 Self {
196 id: id.into(),
197 label: label.into(),
198 closable: true,
199 dirty: false,
200 }
201 }
202
203 /// Set whether this document can be closed from the tab strip.
204 pub fn with_closable(mut self, closable: bool) -> Self {
205 self.closable = closable;
206 self
207 }
208
209 /// Set the unsaved-changes marker.
210 pub fn with_dirty(mut self, dirty: bool) -> Self {
211 self.dirty = dirty;
212 self
213 }
214}
215
216/// What the workspace just did. Returned from every mutating entry point
217/// so the host can react without diffing controller state.
218///
219/// `#[non_exhaustive]`: see [`WorkspaceDoc`]. Match with a trailing
220/// `_ => {}` arm.
221#[non_exhaustive]
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub enum WorkspaceEvent {
224 /// A document was added to the strip and made active.
225 Opened {
226 /// The document's opaque id.
227 id: String,
228 /// Where it landed in the strip.
229 index: usize,
230 },
231 /// A different document became the active one.
232 Activated {
233 /// The now-active document's opaque id.
234 id: String,
235 /// Its index in the strip.
236 index: usize,
237 },
238 /// A document was removed from the strip. Emitted **before** any
239 /// [`Self::Activated`] the close triggers, so a host that drops
240 /// buffers on `Closed` never sees an `Activated` for a document it is
241 /// about to discard.
242 Closed {
243 /// The removed document's opaque id.
244 id: String,
245 /// The index it occupied *before* removal.
246 index: usize,
247 },
248 /// A document moved within the strip.
249 Reordered {
250 /// The moved document's opaque id.
251 id: String,
252 /// Its index before the move.
253 from: usize,
254 /// Its index after the move.
255 to: usize,
256 },
257 /// The preview document became permanent — see the module doc's
258 /// *Preview tabs* section for the six triggers that cause this.
259 /// Emitted exactly once per promotion, never for a document that
260 /// wasn't the preview.
261 Promoted {
262 /// The promoted document's opaque id.
263 id: String,
264 },
265}
266
267/// Resolved rects from one [`WorkspaceController::render`] call.
268///
269/// `#[non_exhaustive]`: see [`WorkspaceDoc`]. Only ever returned by
270/// quadraui, never constructed by consumers, so this forbids exhaustive
271/// destructuring but not field access.
272#[non_exhaustive]
273#[derive(Debug, Clone, Copy, PartialEq)]
274pub struct WorkspaceLayout {
275 /// The tab strip the controller painted — one `line_height` tall,
276 /// flush with the top of the rect it was given.
277 pub strip_bounds: Rect,
278 /// Everything below the strip. The **host** paints the active
279 /// document's content here; the controller draws nothing in it.
280 pub body_bounds: Rect,
281}
282
283/// An ordered set of opaque document ids with one active, rendered as a
284/// [`TabBar`]. See the module documentation for the close-neighbour rule,
285/// the keyboard table, and how this differs from
286/// [`TabGroupController`](crate::compose::tab_group::TabGroupController).
287#[derive(Debug, Clone)]
288pub struct WorkspaceController {
289 bar_id: WidgetId,
290 docs: Vec<WorkspaceDoc>,
291 active: Option<usize>,
292 /// Opaque id of the current preview document, if any. See the module
293 /// doc's *Preview tabs* section. Tracked by id rather than index so
294 /// `close`/`reorder` don't have to keep it in sync by hand.
295 preview: Option<String>,
296 scroll_offset: usize,
297 last_strip: Option<Rect>,
298 #[allow(deprecated)] // `TabBarHits` is `#[deprecated]`
299 last_hits: Option<TabBarHits>,
300}
301
302impl WorkspaceController {
303 /// An empty workspace whose tab strip paints under `bar_id`.
304 ///
305 /// The id is what a driver test passes to
306 /// `TuiDriver::tab_center` / `GtkDriver::tab_center`, so give it
307 /// something stable and unique per strip.
308 pub fn new(bar_id: impl Into<String>) -> Self {
309 Self {
310 bar_id: WidgetId::new(bar_id),
311 docs: Vec::new(),
312 active: None,
313 preview: None,
314 scroll_offset: 0,
315 last_strip: None,
316 last_hits: None,
317 }
318 }
319
320 // ── Queries ─────────────────────────────────────────────────────
321
322 /// The [`WidgetId`] the tab strip paints under.
323 pub fn bar_id(&self) -> &WidgetId {
324 &self.bar_id
325 }
326
327 /// Every open document, in strip order.
328 pub fn docs(&self) -> &[WorkspaceDoc] {
329 &self.docs
330 }
331
332 /// How many documents are open.
333 pub fn len(&self) -> usize {
334 self.docs.len()
335 }
336
337 /// Whether no documents are open.
338 pub fn is_empty(&self) -> bool {
339 self.docs.is_empty()
340 }
341
342 /// Index of the active document, or `None` when the workspace is
343 /// empty.
344 pub fn active_index(&self) -> Option<usize> {
345 self.active
346 }
347
348 /// Opaque id of the active document, or `None` when the workspace is
349 /// empty.
350 pub fn active_id(&self) -> Option<&str> {
351 self.active.map(|i| self.docs[i].id.as_str())
352 }
353
354 /// The active document, or `None` when the workspace is empty.
355 pub fn active_doc(&self) -> Option<&WorkspaceDoc> {
356 self.active.map(|i| &self.docs[i])
357 }
358
359 /// Strip index of `id`, or `None` when it isn't open.
360 pub fn index_of(&self, id: &str) -> Option<usize> {
361 self.docs.iter().position(|d| d.id == id)
362 }
363
364 /// Whether `id` is open.
365 pub fn contains(&self, id: &str) -> bool {
366 self.index_of(id).is_some()
367 }
368
369 /// Whether `id` is the workspace's single preview document. See the
370 /// module doc's *Preview tabs* section. `false` for an unknown id and
371 /// for a permanent (promoted or never-preview) document.
372 pub fn is_preview(&self, id: &str) -> bool {
373 self.preview.as_deref() == Some(id)
374 }
375
376 /// Scroll offset the last [`Self::render`] resolved — the index of
377 /// the leftmost visible tab. Exposed for tests and for hosts that
378 /// mirror the strip elsewhere.
379 pub fn scroll_offset(&self) -> usize {
380 self.scroll_offset
381 }
382
383 // ── Mutation ────────────────────────────────────────────────────
384
385 /// Append `doc` to the strip as a **permanent** tab and make it
386 /// active.
387 ///
388 /// Re-opening an already-open id does **not** duplicate the tab: the
389 /// existing document is activated instead, and the returned events
390 /// contain [`WorkspaceEvent::Activated`] rather than
391 /// [`WorkspaceEvent::Opened`]. (Its label is left alone — the host
392 /// owns labels; use [`Self::set_label`] to change one.)
393 ///
394 /// When `doc.id` is currently the **preview** document, this also
395 /// promotes it — "re-opening it as permanent" is one of the module
396 /// doc's six promotion triggers, and this is the one primitive it
397 /// reduces to (a double click on a source row that already opened its
398 /// preview is just this same call). The returned `Vec` then carries
399 /// both the activation (if the document wasn't already active) and
400 /// [`WorkspaceEvent::Promoted`], in that order. Use [`Self::open_preview`]
401 /// instead when the caller wants the replaceable preview slot, not a
402 /// permanent tab.
403 ///
404 /// Returns an empty `Vec` when nothing changed at all — the document
405 /// was already open, already active, and already permanent.
406 pub fn open(&mut self, doc: WorkspaceDoc) -> Vec<WorkspaceEvent> {
407 if let Some(existing) = self.index_of(&doc.id) {
408 let mut events: Vec<WorkspaceEvent> =
409 self.set_active_index(existing).into_iter().collect();
410 let id = self.docs[existing].id.clone();
411 events.extend(self.promote(&id));
412 return events;
413 }
414 let index = self.docs.len();
415 let id = doc.id.clone();
416 self.docs.push(doc);
417 self.active = Some(index);
418 vec![WorkspaceEvent::Opened { id, index }]
419 }
420
421 /// Open `doc` as the workspace's single **preview** document — see the
422 /// module doc's *Preview tabs* section for the full open-semantics
423 /// table. Never promotes: re-opening the current preview through this
424 /// entry point (branch 1) leaves it exactly as previewed, which is
425 /// what lets a host click through a list of documents without pinning
426 /// each one. Use [`Self::open`] for a permanent tab, and
427 /// [`Self::promote`] to pin the current preview explicitly.
428 pub fn open_preview(&mut self, doc: WorkspaceDoc) -> Vec<WorkspaceEvent> {
429 // 1. Already open — permanent, or the current preview — just
430 // activate it. No new tab, no replace, no promotion.
431 if let Some(existing) = self.index_of(&doc.id) {
432 return self.set_active_index(existing).into_iter().collect();
433 }
434 // 2. A preview tab exists for a different document → replace its
435 // contents in that tab's slot.
436 if let Some(preview_id) = self.preview.clone() {
437 if let Some(index) = self.index_of(&preview_id) {
438 let new_id = doc.id.clone();
439 self.docs[index] = doc;
440 self.preview = Some(new_id.clone());
441 self.active = Some(index);
442 self.clamp_scroll_offset();
443 return vec![
444 WorkspaceEvent::Closed {
445 id: preview_id,
446 index,
447 },
448 WorkspaceEvent::Opened { id: new_id, index },
449 ];
450 }
451 }
452 // 3. Otherwise append a new tab flagged preview.
453 let index = self.docs.len();
454 let id = doc.id.clone();
455 self.docs.push(doc);
456 self.active = Some(index);
457 self.preview = Some(id.clone());
458 vec![WorkspaceEvent::Opened { id, index }]
459 }
460
461 /// Drop `id`'s preview flag, turning it into an ordinary permanent
462 /// tab. Returns [`WorkspaceEvent::Promoted`] when `id` was in fact the
463 /// preview; `None` (a no-op) for an unknown id, or a document that was
464 /// already permanent.
465 ///
466 /// This is the primitive every one of the module doc's six promotion
467 /// triggers reduces to — most of them (saving, an explicit pin) have
468 /// no other `WorkspaceController` hook and call this directly from
469 /// the host's own event handling.
470 pub fn promote(&mut self, id: &str) -> Option<WorkspaceEvent> {
471 if !self.is_preview(id) {
472 return None;
473 }
474 self.preview = None;
475 Some(WorkspaceEvent::Promoted { id: id.to_string() })
476 }
477
478 /// Activate the document with `id`. Returns an empty `Vec` when `id`
479 /// isn't open or is already active. Promotes when `id` is the
480 /// preview — "selecting the preview tab" (see the module doc).
481 pub fn activate(&mut self, id: &str) -> Vec<WorkspaceEvent> {
482 match self.index_of(id) {
483 Some(index) => self.activate_index(index),
484 None => Vec::new(),
485 }
486 }
487
488 /// Activate the document at `index`. Returns an empty `Vec` when
489 /// `index` is out of range. Promotes when the target document is the
490 /// preview — "selecting the preview tab" (see the module doc); this
491 /// runs even when the tab was already active, since selecting an
492 /// already-active preview tab still promotes it. [`Self::cycle`]
493 /// deliberately does **not** go through this — sequential keyboard
494 /// cycling doesn't promote, only an explicit jump to a tab does.
495 pub fn activate_index(&mut self, index: usize) -> Vec<WorkspaceEvent> {
496 let mut events: Vec<WorkspaceEvent> = self.set_active_index(index).into_iter().collect();
497 if let Some(doc) = self.docs.get(index) {
498 let id = doc.id.clone();
499 events.extend(self.promote(&id));
500 }
501 events
502 }
503
504 /// Low-level activation with **no** promotion side effect. Shared by
505 /// [`Self::open`]/[`Self::open_preview`] (which apply their own,
506 /// narrower promotion rules) and [`Self::cycle`] (sequential cycling
507 /// never promotes). Returns `None` when `index` is out of range or
508 /// already active.
509 fn set_active_index(&mut self, index: usize) -> Option<WorkspaceEvent> {
510 if index >= self.docs.len() || self.active == Some(index) {
511 return None;
512 }
513 self.active = Some(index);
514 Some(WorkspaceEvent::Activated {
515 id: self.docs[index].id.clone(),
516 index,
517 })
518 }
519
520 /// Close the document with `id`.
521 ///
522 /// Returns the emitted events in order: always a
523 /// [`WorkspaceEvent::Closed`], followed by a
524 /// [`WorkspaceEvent::Activated`] when the close moved the active
525 /// document (see the module doc's close-neighbour rule). Returns an
526 /// empty `Vec` when `id` isn't open. Closing the last remaining
527 /// document leaves the workspace empty — it does not panic, and it
528 /// still emits `Closed`.
529 ///
530 /// This ignores [`WorkspaceDoc::closable`]: that flag governs the
531 /// *tab strip's* close affordance (see [`Self::handle_click`]), not
532 /// the host's ability to close a document programmatically.
533 pub fn close(&mut self, id: &str) -> Vec<WorkspaceEvent> {
534 let Some(index) = self.index_of(id) else {
535 return Vec::new();
536 };
537 let removed = self.docs.remove(index);
538 if self.preview.as_deref() == Some(removed.id.as_str()) {
539 self.preview = None;
540 }
541 let mut events = vec![WorkspaceEvent::Closed {
542 id: removed.id,
543 index,
544 }];
545
546 let previous_active = self.active;
547 self.active = match previous_active {
548 _ if self.docs.is_empty() => None,
549 // The closed document *was* active: the tab that slid into
550 // its index (its right-hand neighbour) takes over; when it
551 // was last, clamp back onto the new last tab.
552 Some(active) if active == index => Some(index.min(self.docs.len() - 1)),
553 // A tab left of the active one closed: the active document
554 // is unchanged, its index shifted left by one.
555 Some(active) if active > index => Some(active - 1),
556 other => other,
557 };
558
559 // Only report an activation when the *document* changed, not
560 // when the active document's index merely shifted left.
561 let closed_the_active_doc = previous_active == Some(index);
562 if closed_the_active_doc {
563 if let Some(new_active) = self.active {
564 events.push(WorkspaceEvent::Activated {
565 id: self.docs[new_active].id.clone(),
566 index: new_active,
567 });
568 }
569 }
570 self.clamp_scroll_offset();
571 events
572 }
573
574 /// Move the document at `from` to index `to`, shifting the documents
575 /// in between. The **active document is preserved by identity**, not
576 /// by index. Returns `None` when either index is out of range or the
577 /// move is a no-op.
578 pub fn reorder(&mut self, from: usize, to: usize) -> Option<WorkspaceEvent> {
579 if from >= self.docs.len() || to >= self.docs.len() || from == to {
580 return None;
581 }
582 let active_id = self.active_id().map(str::to_string);
583 let doc = self.docs.remove(from);
584 let id = doc.id.clone();
585 self.docs.insert(to, doc);
586 self.active = active_id.and_then(|id| self.index_of(&id));
587 self.clamp_scroll_offset();
588 Some(WorkspaceEvent::Reordered { id, from, to })
589 }
590
591 /// Activate the document `delta` positions from the active one,
592 /// wrapping at both ends. `cycle(1)` is Ctrl+Tab, `cycle(-1)` is
593 /// Ctrl+Shift+Tab. Returns `None` for an empty workspace or when the
594 /// step lands back on the already-active document.
595 ///
596 /// Deliberately does not promote even when it lands on the preview
597 /// tab — see [`Self::activate_index`]'s doc.
598 pub fn cycle(&mut self, delta: isize) -> Option<WorkspaceEvent> {
599 let n = self.docs.len();
600 if n == 0 {
601 return None;
602 }
603 let active = self.active.unwrap_or(0) as isize;
604 let n_i = n as isize;
605 let next = (active + delta).rem_euclid(n_i) as usize;
606 self.set_active_index(next)
607 }
608
609 /// Replace `id`'s tab label. Returns `false` when `id` isn't open.
610 pub fn set_label(&mut self, id: &str, label: impl Into<String>) -> bool {
611 match self.index_of(id) {
612 Some(i) => {
613 self.docs[i].label = label.into();
614 true
615 }
616 None => false,
617 }
618 }
619
620 /// Set `id`'s unsaved-changes marker.
621 ///
622 /// Setting `dirty: true` also promotes `id` when it is the preview
623 /// document — "editing the document" is one of the module doc's six
624 /// promotion triggers, since a host only marks a document dirty in
625 /// response to the user changing it. `dirty: false` (e.g. after a
626 /// save that doesn't also pin) never promotes on its own; a save that
627 /// should promote calls [`Self::promote`] separately.
628 ///
629 /// Returns the events produced — `[Promoted]`, when this promoted;
630 /// otherwise empty, whether or not `id` was found. Use
631 /// [`Self::contains`] first if the caller needs to distinguish
632 /// "unknown id" from "known id, nothing to report".
633 pub fn set_dirty(&mut self, id: &str, dirty: bool) -> Vec<WorkspaceEvent> {
634 let Some(i) = self.index_of(id) else {
635 return Vec::new();
636 };
637 self.docs[i].dirty = dirty;
638 if dirty {
639 self.promote(id).into_iter().collect()
640 } else {
641 Vec::new()
642 }
643 }
644
645 // ── Rendering ───────────────────────────────────────────────────
646
647 /// The [`TabBar`] this controller would paint right now. Exposed so a
648 /// host can measure or mirror the strip without painting it.
649 pub fn tab_bar(&self) -> TabBar {
650 TabBar {
651 id: self.bar_id,
652 tabs: self
653 .docs
654 .iter()
655 .enumerate()
656 .map(|(i, d)| TabItem {
657 label: d.label.clone(),
658 is_active: self.active == Some(i),
659 is_dirty: d.dirty,
660 is_preview: self.is_preview(&d.id),
661 is_closable: d.closable,
662 })
663 .collect(),
664 scroll_offset: self.scroll_offset,
665 right_segments: Vec::new(),
666 active_accent: None,
667 show_tab_close: true,
668 compact: false,
669 }
670 }
671
672 /// Paint the tab strip along the top of `bounds` and return the
673 /// resolved strip / body rects. The host paints the active document's
674 /// content into [`WorkspaceLayout::body_bounds`] itself.
675 ///
676 /// # Keeping the active tab visible
677 ///
678 /// Two things happen here, and both are needed:
679 ///
680 /// 1. **Before painting**, the offset is recomputed from a portable
681 /// char-cell estimate via [`TabBar::fit_active_scroll_offset`].
682 /// Backends whose rasteriser reports `correct_scroll_offset`
683 /// verbatim — the TUI one does, because its char-based fit is
684 /// exact and it therefore trusts whatever the caller stored —
685 /// would otherwise paint the *previous* frame's offset, leaving a
686 /// freshly-activated tab off-screen for a frame.
687 /// 2. **After painting**, a backend that measures in its own unit
688 /// (GTK's Pango widths) may disagree with that estimate and hand
689 /// back a corrected `correct_scroll_offset`. When it does, the
690 /// strip is repainted inline with the corrected value — the
691 /// two-pass-paint pattern `TabBar`'s module doc prescribes for
692 /// event-driven backends, where a queued redraw is unreliable.
693 #[allow(deprecated)] // `Backend::draw_tab_bar` returns `TabBarHits` — issue #823
694 pub fn render(&mut self, backend: &mut dyn Backend, bounds: Rect) -> WorkspaceLayout {
695 let strip_height = backend.chrome_line_height().min(bounds.height.max(0.0));
696 let strip = Rect::new(bounds.x, bounds.y, bounds.width, strip_height);
697 let body = Rect::new(
698 bounds.x,
699 bounds.y + strip_height,
700 bounds.width,
701 (bounds.height - strip_height).max(0.0),
702 );
703
704 self.scroll_offset = self.estimate_scroll_offset(strip.width, backend.char_width());
705 let mut hits = backend.draw_tab_bar(strip, &self.tab_bar(), None);
706 if hits.correct_scroll_offset != self.scroll_offset {
707 self.scroll_offset = hits.correct_scroll_offset.min(self.max_scroll_offset());
708 hits = backend.draw_tab_bar(strip, &self.tab_bar(), None);
709 }
710
711 self.last_strip = Some(strip);
712 self.last_hits = Some(hits);
713 WorkspaceLayout {
714 strip_bounds: strip,
715 body_bounds: body,
716 }
717 }
718
719 /// The strip rect the last [`Self::render`] painted, if any.
720 pub fn strip_bounds(&self) -> Option<Rect> {
721 self.last_strip
722 }
723
724 // ── Input ───────────────────────────────────────────────────────
725
726 /// Route a mouse click at `(x, y)` in surface coordinates.
727 ///
728 /// - [`MouseButton::Middle`] anywhere on a tab (body *or* close
729 /// button) closes it — VS Code's middle-click-to-close, and the
730 /// module doc's "middle-click closes a tab" contract. Every other
731 /// button falls through to the existing left-click behaviour: a
732 /// click on a tab's close button closes it (close buttons win over
733 /// tab bodies, matching
734 /// [`TabGroupController::handle_click`](crate::compose::tab_group::TabGroupController::handle_click));
735 /// a click on a tab body activates it, promoting it when it's the
736 /// preview (see [`Self::activate_index`]).
737 ///
738 /// Both close paths ignore [`WorkspaceDoc::closable`]: a `false` tab
739 /// closes for neither the close button nor a middle click. Anything
740 /// else — the body area, dead space in the strip, a click before the
741 /// first [`Self::render`] — returns an empty `Vec`.
742 #[allow(deprecated)] // reads the deprecated `TabBarHits` cache — issue #823
743 pub fn handle_click(&mut self, x: f32, y: f32, button: MouseButton) -> Vec<WorkspaceEvent> {
744 let (Some(strip), Some(hits)) = (self.last_strip, self.last_hits.as_ref()) else {
745 return Vec::new();
746 };
747 if y < strip.y || y >= strip.y + strip.height || x < strip.x || x >= strip.x + strip.width {
748 return Vec::new();
749 }
750 let click_x = x as f64;
751 let in_range = |(start, end): (f64, f64)| end > start && click_x >= start && click_x < end;
752
753 let body_hit = hits
754 .slot_positions
755 .iter()
756 .enumerate()
757 .find(|(_, range)| in_range(**range))
758 .map(|(i, _)| i);
759
760 if button == MouseButton::Middle {
761 // Middle-click closes whatever tab is under the pointer,
762 // anywhere on its span — unlike the close button, it doesn't
763 // require landing on the × glyph specifically.
764 let Some(i) = body_hit else {
765 return Vec::new();
766 };
767 let Some(doc) = self.docs.get(i) else {
768 return Vec::new();
769 };
770 if !doc.closable {
771 return Vec::new();
772 }
773 let id = doc.id.clone();
774 return self.close(&id);
775 }
776
777 // Close buttons take precedence over the tab bodies that contain
778 // them.
779 let close_hit = hits
780 .close_bounds
781 .iter()
782 .enumerate()
783 .find(|(_, cb)| cb.is_some_and(in_range))
784 .map(|(i, _)| i);
785 if let Some(i) = close_hit {
786 let Some(doc) = self.docs.get(i) else {
787 return Vec::new();
788 };
789 if !doc.closable {
790 return Vec::new();
791 }
792 let id = doc.id.clone();
793 return self.close(&id);
794 }
795
796 match body_hit {
797 Some(i) => self.activate_index(i),
798 None => Vec::new(),
799 }
800 }
801
802 /// Route a key press. Returns the emitted event, or `None` when the
803 /// key isn't one this controller binds (the caller should keep
804 /// dispatching it).
805 ///
806 /// See the module doc for the full binding table: `Ctrl+Tab` /
807 /// `Ctrl+PageDown` step forward, `Ctrl+Shift+Tab` / `Ctrl+PageUp`
808 /// step back, both wrapping.
809 pub fn handle_key(&mut self, key: &Key, modifiers: Modifiers) -> Option<WorkspaceEvent> {
810 if !modifiers.ctrl {
811 return None;
812 }
813 let delta = match key {
814 // Crossterm reports Ctrl+Shift+Tab as `BackTab`; GTK reports a
815 // shifted `Tab`. Accept both spellings so the binding works on
816 // every backend without the consumer normalising first.
817 Key::Named(NamedKey::BackTab) => -1,
818 Key::Named(NamedKey::Tab) if modifiers.shift => -1,
819 Key::Named(NamedKey::Tab) => 1,
820 Key::Named(NamedKey::PageUp) => -1,
821 Key::Named(NamedKey::PageDown) => 1,
822 _ => return None,
823 };
824 self.cycle(delta)
825 }
826
827 // ── Internals ───────────────────────────────────────────────────
828
829 /// Largest offset that still shows at least one tab.
830 fn max_scroll_offset(&self) -> usize {
831 self.docs.len().saturating_sub(1)
832 }
833
834 fn clamp_scroll_offset(&mut self) {
835 self.scroll_offset = self.scroll_offset.min(self.max_scroll_offset());
836 }
837
838 /// Portable pre-paint scroll offset: measure each tab in char cells
839 /// (label display width plus the close affordance) and ask the
840 /// primitive where the active tab fits. Exact for the TUI rasteriser,
841 /// an estimate for pixel backends — which correct it themselves via
842 /// `TabBarHits::correct_scroll_offset`, see [`Self::render`].
843 fn estimate_scroll_offset(&self, strip_width: f32, char_width: f32) -> usize {
844 let Some(active) = self.active else {
845 return 0;
846 };
847 let cell = if char_width > 0.0 { char_width } else { 1.0 };
848 let available = (strip_width / cell).max(0.0) as usize;
849 TabBar::fit_active_scroll_offset(active, self.docs.len(), available, |i| {
850 let doc = &self.docs[i];
851 display_width(&doc.label) + if doc.closable { CLOSE_COLS_ESTIMATE } else { 0 }
852 })
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 fn ws(ids: &[&str]) -> WorkspaceController {
861 let mut ws = WorkspaceController::new("test:workspace");
862 for id in ids {
863 ws.open(WorkspaceDoc::new(*id, *id));
864 }
865 ws
866 }
867
868 fn ids(ws: &WorkspaceController) -> Vec<&str> {
869 ws.docs().iter().map(|d| d.id.as_str()).collect()
870 }
871
872 #[test]
873 fn open_appends_and_activates() {
874 let mut ws = WorkspaceController::new("w");
875 assert!(ws.is_empty());
876 assert_eq!(ws.active_id(), None);
877
878 let ev = ws.open(WorkspaceDoc::new("a", "alpha"));
879 assert_eq!(
880 ev,
881 vec![WorkspaceEvent::Opened {
882 id: "a".into(),
883 index: 0
884 }]
885 );
886 let ev = ws.open(WorkspaceDoc::new("b", "beta"));
887 assert_eq!(
888 ev,
889 vec![WorkspaceEvent::Opened {
890 id: "b".into(),
891 index: 1
892 }]
893 );
894 assert_eq!(ids(&ws), ["a", "b"]);
895 assert_eq!(ws.active_id(), Some("b"));
896 assert_eq!(ws.len(), 2);
897 }
898
899 #[test]
900 fn reopening_an_open_id_activates_instead_of_duplicating() {
901 let mut ws = ws(&["a", "b", "c"]);
902 let ev = ws.open(WorkspaceDoc::new("a", "alpha (again)"));
903 assert_eq!(
904 ev,
905 vec![WorkspaceEvent::Activated {
906 id: "a".into(),
907 index: 0
908 }]
909 );
910 assert_eq!(ids(&ws), ["a", "b", "c"], "no duplicate tab");
911 assert_eq!(
912 ws.docs()[0].label,
913 "a",
914 "re-open must not silently relabel an existing document"
915 );
916 // Re-opening the already-active one is a no-op — it's permanent,
917 // not the preview, so there's nothing to promote either.
918 assert_eq!(ws.open(WorkspaceDoc::new("a", "alpha")), vec![]);
919 }
920
921 #[test]
922 fn activate_reports_only_real_changes() {
923 let mut ws = ws(&["a", "b"]);
924 assert_eq!(ws.active_id(), Some("b"));
925 assert_eq!(ws.activate("b"), vec![], "already active");
926 assert_eq!(ws.activate("nope"), vec![], "unknown id");
927 assert_eq!(
928 ws.activate("a"),
929 vec![WorkspaceEvent::Activated {
930 id: "a".into(),
931 index: 0
932 }]
933 );
934 }
935
936 // ── Close-neighbour rule (module doc) ───────────────────────────
937
938 #[test]
939 fn close_active_activates_right_neighbour() {
940 let mut ws = ws(&["a", "b", "c"]);
941 ws.activate("b");
942 let events = ws.close("b");
943 assert_eq!(
944 events,
945 vec![
946 WorkspaceEvent::Closed {
947 id: "b".into(),
948 index: 1
949 },
950 WorkspaceEvent::Activated {
951 id: "c".into(),
952 index: 1
953 },
954 ],
955 "Closed must precede the Activated it triggers"
956 );
957 assert_eq!(ids(&ws), ["a", "c"]);
958 assert_eq!(ws.active_id(), Some("c"));
959 }
960
961 #[test]
962 fn close_last_active_activates_left_neighbour() {
963 let mut ws = ws(&["a", "b", "c"]);
964 assert_eq!(ws.active_id(), Some("c"));
965 let events = ws.close("c");
966 assert_eq!(
967 events,
968 vec![
969 WorkspaceEvent::Closed {
970 id: "c".into(),
971 index: 2
972 },
973 WorkspaceEvent::Activated {
974 id: "b".into(),
975 index: 1
976 },
977 ]
978 );
979 assert_eq!(ws.active_id(), Some("b"));
980 }
981
982 #[test]
983 fn closing_a_non_active_document_keeps_the_active_one() {
984 let mut ws = ws(&["a", "b", "c"]);
985 ws.activate("c");
986 // Close a tab to the *left* of the active one: index shifts, the
987 // active document does not change, and no Activated is emitted.
988 let events = ws.close("a");
989 assert_eq!(
990 events,
991 vec![WorkspaceEvent::Closed {
992 id: "a".into(),
993 index: 0
994 }]
995 );
996 assert_eq!(ws.active_id(), Some("c"));
997 assert_eq!(ws.active_index(), Some(1), "index tracked the shift");
998
999 // …and one to the right.
1000 let mut ws = ws2();
1001 ws.activate("a");
1002 let events = ws.close("c");
1003 assert_eq!(
1004 events,
1005 vec![WorkspaceEvent::Closed {
1006 id: "c".into(),
1007 index: 2
1008 }]
1009 );
1010 assert_eq!(ws.active_id(), Some("a"));
1011 assert_eq!(ws.active_index(), Some(0));
1012 }
1013
1014 fn ws2() -> WorkspaceController {
1015 ws(&["a", "b", "c"])
1016 }
1017
1018 #[test]
1019 fn closing_the_last_document_empties_the_workspace_without_panicking() {
1020 let mut ws = ws(&["only"]);
1021 let events = ws.close("only");
1022 assert_eq!(
1023 events,
1024 vec![WorkspaceEvent::Closed {
1025 id: "only".into(),
1026 index: 0
1027 }],
1028 "no Activated — there is nothing left to activate"
1029 );
1030 assert!(ws.is_empty());
1031 assert_eq!(ws.active_id(), None);
1032 assert_eq!(ws.active_index(), None);
1033 // Every query and cycle stays safe on an empty workspace.
1034 assert_eq!(ws.cycle(1), None);
1035 assert_eq!(ws.close("only"), vec![], "closing twice is a no-op");
1036 assert!(ws.tab_bar().tabs.is_empty());
1037 }
1038
1039 #[test]
1040 fn close_unknown_id_is_a_noop() {
1041 let mut ws = ws(&["a"]);
1042 assert_eq!(ws.close("ghost"), vec![]);
1043 assert_eq!(ws.len(), 1);
1044 }
1045
1046 // ── Cycling ─────────────────────────────────────────────────────
1047
1048 #[test]
1049 fn cycle_wraps_in_both_directions() {
1050 let mut ws = ws(&["a", "b", "c"]);
1051 ws.activate("a");
1052 assert_eq!(ws.cycle(1).map(event_id), Some("b".to_string()));
1053 assert_eq!(ws.cycle(1).map(event_id), Some("c".to_string()));
1054 assert_eq!(ws.cycle(1).map(event_id), Some("a".to_string()), "wraps");
1055 assert_eq!(
1056 ws.cycle(-1).map(event_id),
1057 Some("c".to_string()),
1058 "wraps backwards"
1059 );
1060 }
1061
1062 #[test]
1063 fn cycle_on_a_single_document_is_a_noop() {
1064 let mut ws = ws(&["a"]);
1065 assert_eq!(ws.cycle(1), None);
1066 assert_eq!(ws.active_id(), Some("a"));
1067 }
1068
1069 fn event_id(ev: WorkspaceEvent) -> String {
1070 match ev {
1071 WorkspaceEvent::Opened { id, .. }
1072 | WorkspaceEvent::Activated { id, .. }
1073 | WorkspaceEvent::Closed { id, .. }
1074 | WorkspaceEvent::Reordered { id, .. }
1075 | WorkspaceEvent::Promoted { id } => id,
1076 }
1077 }
1078
1079 // ── Keyboard ────────────────────────────────────────────────────
1080
1081 fn ctrl() -> Modifiers {
1082 Modifiers {
1083 ctrl: true,
1084 ..Modifiers::default()
1085 }
1086 }
1087
1088 fn ctrl_shift() -> Modifiers {
1089 Modifiers {
1090 ctrl: true,
1091 shift: true,
1092 ..Modifiers::default()
1093 }
1094 }
1095
1096 #[test]
1097 fn ctrl_tab_and_page_keys_cycle() {
1098 let mut ws = ws(&["a", "b", "c"]);
1099 ws.activate("a");
1100
1101 assert_eq!(
1102 ws.handle_key(&Key::Named(NamedKey::Tab), ctrl())
1103 .map(event_id),
1104 Some("b".to_string())
1105 );
1106 assert_eq!(
1107 ws.handle_key(&Key::Named(NamedKey::Tab), ctrl_shift())
1108 .map(event_id),
1109 Some("a".to_string())
1110 );
1111 assert_eq!(
1112 ws.handle_key(&Key::Named(NamedKey::BackTab), ctrl())
1113 .map(event_id),
1114 Some("c".to_string()),
1115 "crossterm's Ctrl+Shift+Tab spelling"
1116 );
1117 assert_eq!(
1118 ws.handle_key(&Key::Named(NamedKey::PageDown), ctrl())
1119 .map(event_id),
1120 Some("a".to_string())
1121 );
1122 assert_eq!(
1123 ws.handle_key(&Key::Named(NamedKey::PageUp), ctrl())
1124 .map(event_id),
1125 Some("c".to_string())
1126 );
1127 }
1128
1129 #[test]
1130 fn unbound_keys_are_left_for_the_caller() {
1131 let mut ws = ws(&["a", "b"]);
1132 assert_eq!(
1133 ws.handle_key(&Key::Named(NamedKey::Tab), Modifiers::default()),
1134 None,
1135 "plain Tab is not the workspace's key"
1136 );
1137 assert_eq!(ws.handle_key(&Key::Char('q'), ctrl()), None);
1138 assert_eq!(
1139 ws.handle_key(&Key::Named(NamedKey::Right), ctrl()),
1140 None,
1141 "arrows belong to whatever owns the body"
1142 );
1143 }
1144
1145 // ── Reorder ─────────────────────────────────────────────────────
1146
1147 #[test]
1148 fn reorder_moves_a_tab_and_preserves_the_active_document() {
1149 let mut ws = ws(&["a", "b", "c"]);
1150 ws.activate("a");
1151 let ev = ws.reorder(0, 2);
1152 assert_eq!(
1153 ev,
1154 Some(WorkspaceEvent::Reordered {
1155 id: "a".into(),
1156 from: 0,
1157 to: 2
1158 })
1159 );
1160 assert_eq!(ids(&ws), ["b", "c", "a"]);
1161 assert_eq!(
1162 ws.active_id(),
1163 Some("a"),
1164 "active is preserved by identity, not index"
1165 );
1166 assert_eq!(ws.active_index(), Some(2));
1167 }
1168
1169 #[test]
1170 fn reorder_rejects_out_of_range_and_noop_moves() {
1171 let mut ws = ws(&["a", "b"]);
1172 assert_eq!(ws.reorder(0, 0), None);
1173 assert_eq!(ws.reorder(5, 0), None);
1174 assert_eq!(ws.reorder(0, 5), None);
1175 assert_eq!(ids(&ws), ["a", "b"]);
1176 }
1177
1178 // ── Tab bar projection ──────────────────────────────────────────
1179
1180 #[test]
1181 fn tab_bar_marks_exactly_one_active_tab_and_carries_doc_flags() {
1182 let mut ws = WorkspaceController::new("w");
1183 ws.open(WorkspaceDoc::new("a", "alpha"));
1184 ws.open(WorkspaceDoc::new("b", "beta").with_closable(false));
1185 ws.set_dirty("a", true);
1186 ws.activate("a");
1187
1188 let bar = ws.tab_bar();
1189 assert_eq!(bar.id.as_str(), "w");
1190 assert_eq!(bar.tabs.len(), 2);
1191 assert_eq!(
1192 bar.tabs.iter().filter(|t| t.is_active).count(),
1193 1,
1194 "open-N-view-one: exactly one active tab"
1195 );
1196 assert!(bar.tabs[0].is_active);
1197 assert!(bar.tabs[0].is_dirty);
1198 assert!(bar.tabs[0].is_closable);
1199 assert!(!bar.tabs[1].is_closable);
1200 assert_eq!(bar.tabs[1].label, "beta");
1201 }
1202
1203 #[test]
1204 fn set_label_reports_unknown_ids() {
1205 let mut ws = ws(&["a"]);
1206 assert!(ws.set_label("a", "renamed"));
1207 assert!(!ws.set_label("ghost", "x"));
1208 assert_eq!(ws.docs()[0].label, "renamed");
1209 }
1210
1211 #[test]
1212 fn set_dirty_sets_the_flag_regardless_of_preview_state() {
1213 let mut ws = ws(&["a"]);
1214 // "a" is an ordinary permanent tab — not the preview — so marking
1215 // it dirty has no events to report. `set_dirty` doesn't return
1216 // whether the id was known (see its doc); use `contains` for
1217 // that.
1218 assert!(ws.contains("a"));
1219 assert_eq!(ws.set_dirty("a", true), vec![]);
1220 assert!(!ws.contains("ghost"));
1221 assert_eq!(ws.set_dirty("ghost", true), vec![], "unknown id is a no-op");
1222 assert!(ws.docs()[0].dirty);
1223 }
1224
1225 // ── Overflow ────────────────────────────────────────────────────
1226
1227 #[test]
1228 fn scroll_offset_estimate_keeps_the_active_tab_visible() {
1229 // 8 tabs × 10 columns each ("doc-0" is 5 wide + 2 close cols = 7;
1230 // use longer labels so the arithmetic is unambiguous) in a
1231 // 30-column strip: only three fit at a time.
1232 let mut ws = WorkspaceController::new("w");
1233 for i in 0..8 {
1234 ws.open(WorkspaceDoc::new(format!("d{i}"), format!("docum{i}")));
1235 }
1236 // "docum0" = 6 cols + 2 close = 8 per tab; 30 / 8 = 3 tabs fit.
1237 ws.activate("d0");
1238 assert_eq!(ws.estimate_scroll_offset(30.0, 1.0), 0);
1239
1240 ws.activate("d7");
1241 let offset = ws.estimate_scroll_offset(30.0, 1.0);
1242 assert!(
1243 offset > 0 && offset <= 7,
1244 "activating the last tab must scroll it into view, got {offset}"
1245 );
1246 assert!(
1247 (7 - offset) * 8 <= 30,
1248 "tabs from the resolved offset through the active one must fit \
1249 in 30 columns, offset was {offset}"
1250 );
1251
1252 ws.activate("d0");
1253 assert_eq!(
1254 ws.estimate_scroll_offset(30.0, 1.0),
1255 0,
1256 "activating the first tab scrolls back to the start"
1257 );
1258 }
1259
1260 #[test]
1261 fn scroll_offset_estimate_is_zero_when_everything_fits() {
1262 let mut ws = ws(&["a", "b", "c"]);
1263 ws.activate("c");
1264 assert_eq!(ws.estimate_scroll_offset(200.0, 1.0), 0);
1265 }
1266
1267 #[test]
1268 fn scroll_offset_estimate_survives_degenerate_geometry() {
1269 let mut ws = ws(&["a", "b", "c"]);
1270 ws.activate("c");
1271 // A zero/negative char width must not divide by zero or panic,
1272 // and must clamp to the same estimate a `char_width` of exactly
1273 // `1.0` (the fallback `estimate_scroll_offset` substitutes for
1274 // any non-positive value) would have produced — not some other
1275 // garbage value that happens not to crash.
1276 let fallback = ws.estimate_scroll_offset(0.0, 1.0);
1277 assert_eq!(ws.estimate_scroll_offset(0.0, 0.0), fallback);
1278 assert_eq!(ws.estimate_scroll_offset(-5.0, 1.0), fallback);
1279 }
1280
1281 #[test]
1282 fn click_before_the_first_render_is_ignored() {
1283 let mut ws = ws(&["a", "b"]);
1284 assert_eq!(
1285 ws.handle_click(3.0, 0.0, MouseButton::Left),
1286 vec![],
1287 "no cached strip geometry yet"
1288 );
1289 }
1290
1291 /// The controller's portable pre-paint measurement (see
1292 /// [`CLOSE_COLS_ESTIMATE`]) must agree with what the TUI rasteriser
1293 /// actually reserves, or the estimate silently drifts and the active
1294 /// tab lands off-screen at the exact boundary widths.
1295 #[cfg(feature = "tui")]
1296 #[test]
1297 fn close_cols_estimate_matches_tui_rasteriser() {
1298 assert_eq!(CLOSE_COLS_ESTIMATE, crate::tui::TAB_CLOSE_COLS as usize);
1299 }
1300
1301 // ── Preview tabs ────────────────────────────────────────────────
1302
1303 #[test]
1304 fn is_preview_is_false_for_unknown_and_permanent_docs() {
1305 let ws = ws(&["a"]);
1306 assert!(!ws.is_preview("a"), "a plain open() never sets preview");
1307 assert!(!ws.is_preview("ghost"));
1308 }
1309
1310 #[test]
1311 fn open_preview_appends_a_flagged_tab_and_activates_it() {
1312 let mut ws = WorkspaceController::new("w");
1313 let events = ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1314 assert_eq!(
1315 events,
1316 vec![WorkspaceEvent::Opened {
1317 id: "a".into(),
1318 index: 0
1319 }]
1320 );
1321 assert!(ws.is_preview("a"));
1322 assert_eq!(ws.active_id(), Some("a"));
1323 assert!(
1324 ws.tab_bar().tabs[0].is_preview,
1325 "must feed TabItem::is_preview"
1326 );
1327 }
1328
1329 #[test]
1330 fn open_preview_of_a_second_doc_replaces_the_first_in_place() {
1331 // Acceptance: opening doc A as preview, then doc B as preview,
1332 // leaves one tab, showing B.
1333 let mut ws = WorkspaceController::new("w");
1334 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1335 let events = ws.open_preview(WorkspaceDoc::new("b", "beta"));
1336 assert_eq!(
1337 events,
1338 vec![
1339 WorkspaceEvent::Closed {
1340 id: "a".into(),
1341 index: 0
1342 },
1343 WorkspaceEvent::Opened {
1344 id: "b".into(),
1345 index: 0
1346 },
1347 ]
1348 );
1349 assert_eq!(ids(&ws), ["b"], "one tab, not two");
1350 assert_eq!(ws.active_id(), Some("b"));
1351 assert!(ws.is_preview("b"));
1352 assert!(
1353 !ws.is_preview("a"),
1354 "a's slot was taken over, not preserved"
1355 );
1356 }
1357
1358 #[test]
1359 fn promoting_then_opening_another_preview_yields_two_tabs() {
1360 // Acceptance: after promote(A), opening B as preview yields two
1361 // tabs — A permanent, B preview.
1362 let mut ws = WorkspaceController::new("w");
1363 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1364 assert_eq!(
1365 ws.promote("a"),
1366 Some(WorkspaceEvent::Promoted { id: "a".into() })
1367 );
1368 let events = ws.open_preview(WorkspaceDoc::new("b", "beta"));
1369 assert_eq!(
1370 events,
1371 vec![WorkspaceEvent::Opened {
1372 id: "b".into(),
1373 index: 1
1374 }]
1375 );
1376 assert_eq!(ids(&ws), ["a", "b"]);
1377 assert!(!ws.is_preview("a"), "a was promoted, so it keeps its slot");
1378 assert!(ws.is_preview("b"));
1379 }
1380
1381 #[test]
1382 fn open_preview_of_an_already_permanent_doc_activates_without_touching_the_preview_slot() {
1383 // Acceptance: opening a document already present as a permanent
1384 // tab activates it and does not disturb the preview slot.
1385 let mut ws = WorkspaceController::new("w");
1386 ws.open(WorkspaceDoc::new("a", "alpha")); // permanent
1387 ws.open_preview(WorkspaceDoc::new("b", "beta")); // preview, active
1388 assert_eq!(ws.active_id(), Some("b"));
1389
1390 let events = ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1391 assert_eq!(
1392 events,
1393 vec![WorkspaceEvent::Activated {
1394 id: "a".into(),
1395 index: 0
1396 }]
1397 );
1398 assert_eq!(ids(&ws), ["a", "b"], "no new tab, no replace");
1399 assert_eq!(ws.active_id(), Some("a"));
1400 assert!(ws.is_preview("b"), "b's preview slot is untouched");
1401 }
1402
1403 #[test]
1404 fn open_preview_of_the_current_preview_reactivates_without_promoting() {
1405 // Re-navigating to the document that's already the preview must
1406 // not promote it — otherwise clicking through a list of files
1407 // would pin the first one on the second glance.
1408 let mut ws = WorkspaceController::new("w");
1409 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1410 ws.activate_index(1_000_000); // no-op, keeps "a" active for clarity
1411 let events = ws.open_preview(WorkspaceDoc::new("a", "alpha again"));
1412 assert_eq!(events, vec![], "already open and already active");
1413 assert!(ws.is_preview("a"), "must stay preview");
1414 assert_eq!(
1415 ws.docs()[0].label,
1416 "alpha",
1417 "re-open via open_preview doesn't relabel either"
1418 );
1419 }
1420
1421 #[test]
1422 fn promote_is_a_noop_for_unknown_or_non_preview_ids() {
1423 let mut ws = ws(&["a"]);
1424 assert_eq!(ws.promote("a"), None, "a is permanent, not preview");
1425 assert_eq!(ws.promote("ghost"), None);
1426 }
1427
1428 #[test]
1429 fn promote_is_idempotent_emitting_the_event_exactly_once() {
1430 let mut ws = WorkspaceController::new("w");
1431 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1432 assert_eq!(
1433 ws.promote("a"),
1434 Some(WorkspaceEvent::Promoted { id: "a".into() })
1435 );
1436 assert!(!ws.is_preview("a"));
1437 assert_eq!(ws.promote("a"), None, "already permanent — no second event");
1438 }
1439
1440 #[test]
1441 fn closing_the_preview_tab_clears_the_preview_slot() {
1442 let mut ws = WorkspaceController::new("w");
1443 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1444 ws.close("a");
1445 // A later doc reusing a similar id must not inherit a dangling
1446 // preview flag from the closed tab.
1447 ws.open(WorkspaceDoc::new("b", "beta"));
1448 assert!(!ws.is_preview("b"));
1449 }
1450
1451 // ── The six promotion triggers (module doc) ─────────────────────
1452
1453 #[test]
1454 fn trigger_reopening_the_preview_as_permanent_promotes_it() {
1455 // Covers both "re-opening it as permanent" and, since vimcode's
1456 // double-click-on-the-source-row handler is just this same call,
1457 // "double click on the source row": both reduce to `open()` on an
1458 // id that's currently the preview.
1459 let mut ws = WorkspaceController::new("w");
1460 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1461 assert!(ws.is_preview("a"));
1462
1463 let events = ws.open(WorkspaceDoc::new("a", "alpha"));
1464 assert_eq!(events, vec![WorkspaceEvent::Promoted { id: "a".into() }]);
1465 assert!(!ws.is_preview("a"));
1466 }
1467
1468 #[test]
1469 fn trigger_selecting_the_preview_tab_via_activate_promotes_it() {
1470 let mut ws = WorkspaceController::new("w");
1471 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1472 ws.open(WorkspaceDoc::new("b", "beta")); // active moves off "a"
1473 assert!(ws.is_preview("a"));
1474
1475 let events = ws.activate("a");
1476 assert_eq!(
1477 events,
1478 vec![
1479 WorkspaceEvent::Activated {
1480 id: "a".into(),
1481 index: 0
1482 },
1483 WorkspaceEvent::Promoted { id: "a".into() },
1484 ],
1485 "selecting the preview tab promotes it — the deliberate VS Code divergence"
1486 );
1487 assert!(!ws.is_preview("a"));
1488 }
1489
1490 #[test]
1491 fn trigger_clicking_the_preview_tab_promotes_it() {
1492 let mut ws = WorkspaceController::new("w");
1493 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1494 ws.open(WorkspaceDoc::new("b", "beta"));
1495 assert!(ws.is_preview("a"));
1496
1497 seed_click_geometry(
1498 &mut ws,
1499 Rect::new(0.0, 0.0, 80.0, 1.0),
1500 &[(0.0, 10.0), (10.0, 20.0)],
1501 );
1502 let events = ws.handle_click(5.0, 0.5, MouseButton::Left);
1503 assert!(
1504 events.contains(&WorkspaceEvent::Promoted { id: "a".into() }),
1505 "left click on the preview tab must promote it: {events:?}"
1506 );
1507 assert!(!ws.is_preview("a"));
1508 }
1509
1510 #[test]
1511 fn trigger_editing_the_preview_document_promotes_it() {
1512 let mut ws = WorkspaceController::new("w");
1513 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1514 let events = ws.set_dirty("a", true);
1515 assert_eq!(events, vec![WorkspaceEvent::Promoted { id: "a".into() }]);
1516 assert!(!ws.is_preview("a"));
1517 assert!(ws.docs()[0].dirty, "the dirty flag itself is still set");
1518 }
1519
1520 #[test]
1521 fn trigger_saving_promotes_the_preview_document() {
1522 // WorkspaceController owns no persistence (module doc's
1523 // non-goals), so "on save" is the host calling `promote`
1524 // directly from its save handler.
1525 let mut ws = WorkspaceController::new("w");
1526 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1527 ws.set_dirty("a", true); // already promotes via the edit trigger…
1528 assert!(!ws.is_preview("a"));
1529 // …so re-preview it to exercise "saving" as its own trigger.
1530 ws.open_preview(WorkspaceDoc::new("c", "gamma"));
1531 ws.promote("c").expect("save handler promotes the preview");
1532 assert!(!ws.is_preview("c"));
1533 }
1534
1535 #[test]
1536 fn trigger_explicit_pin_promotes_the_preview_document() {
1537 let mut ws = WorkspaceController::new("w");
1538 ws.open_preview(WorkspaceDoc::new("a", "alpha"));
1539 assert!(ws.is_preview("a"));
1540 // A pin button in the host's chrome calls the same primitive.
1541 let event = ws.promote("a");
1542 assert_eq!(event, Some(WorkspaceEvent::Promoted { id: "a".into() }));
1543 assert!(!ws.is_preview("a"));
1544 }
1545
1546 #[test]
1547 fn cycle_does_not_promote_even_when_it_lands_on_the_preview_tab() {
1548 let mut ws = WorkspaceController::new("w");
1549 ws.open(WorkspaceDoc::new("a", "alpha"));
1550 ws.open_preview(WorkspaceDoc::new("b", "beta"));
1551 ws.activate("a");
1552 assert!(ws.is_preview("b"));
1553
1554 let ev = ws.cycle(1); // a -> b
1555 assert_eq!(ev.map(event_id), Some("b".to_string()));
1556 assert!(
1557 ws.is_preview("b"),
1558 "sequential cycling must not promote — only an explicit jump does"
1559 );
1560 }
1561
1562 #[test]
1563 fn middle_click_closes_a_tab_left_click_activates() {
1564 let mut ws = WorkspaceController::new("w");
1565 ws.open(WorkspaceDoc::new("a", "alpha"));
1566 ws.open(WorkspaceDoc::new("b", "beta"));
1567 ws.activate("a");
1568
1569 seed_click_geometry(
1570 &mut ws,
1571 Rect::new(0.0, 0.0, 80.0, 1.0),
1572 &[(0.0, 10.0), (10.0, 20.0)],
1573 );
1574
1575 let events = ws.handle_click(15.0, 0.5, MouseButton::Middle);
1576 assert_eq!(
1577 events,
1578 vec![WorkspaceEvent::Closed {
1579 id: "b".into(),
1580 index: 1
1581 }],
1582 "middle click closes, it does not activate first"
1583 );
1584 assert_eq!(ids(&ws), ["a"]);
1585 assert_eq!(
1586 ws.active_id(),
1587 Some("a"),
1588 "middle-closing a non-active tab must not disturb the active one"
1589 );
1590 }
1591
1592 #[test]
1593 fn middle_click_respects_the_closable_flag() {
1594 let mut ws = WorkspaceController::new("w");
1595 ws.open(WorkspaceDoc::new("a", "alpha").with_closable(false));
1596
1597 seed_click_geometry(&mut ws, Rect::new(0.0, 0.0, 80.0, 1.0), &[(0.0, 10.0)]);
1598
1599 assert_eq!(ws.handle_click(5.0, 0.5, MouseButton::Middle), vec![]);
1600 assert_eq!(ws.len(), 1, "non-closable tab survives a middle click");
1601 }
1602
1603 /// Test-only: seed the controller's cached click geometry as if
1604 /// [`WorkspaceController::render`] had just painted `bounds`, tab `i`
1605 /// spanning `tabs[i]`. Exercises `handle_click`'s real hit-testing and
1606 /// event logic without a `Backend`; the rasteriser step itself
1607 /// (actually painting a `TabBar` into those bounds) is covered
1608 /// end-to-end by the driver tests in `tests/tui_example_driver.rs`.
1609 #[allow(deprecated)] // builds the deprecated `TabBarHits` test fixture — issue #823
1610 fn seed_click_geometry(ws: &mut WorkspaceController, bounds: Rect, tabs: &[(f64, f64)]) {
1611 ws.last_strip = Some(bounds);
1612 ws.last_hits = Some(TabBarHits {
1613 slot_positions: tabs.to_vec(),
1614 close_bounds: vec![None; tabs.len()],
1615 right_segment_bounds: Vec::new(),
1616 available_cols: bounds.width as usize,
1617 correct_scroll_offset: 0,
1618 });
1619 }
1620}