Skip to main content

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}