Skip to main content

damascene_core/widgets/
editor_tabs.rs

1//! Editor tabs — the closeable, addable tab strip familiar from VS
2//! Code, Chrome, and Ant Design's `Tabs type="editable-card"`. Each
3//! tab carries a label and a close (`×`) affordance; a trailing `+`
4//! button asks the app to open a new tab.
5//!
6//! Distinct from [`crate::widgets::tabs`], which models the shadcn /
7//! Radix segmented-control pattern (a muted pill with one active
8//! trigger raised inside it). Use `tabs_list` for view-mode toggles
9//! and settings-style category pickers; reach for `editor_tabs` when
10//! the tabs represent **opened documents** the user can close and
11//! create.
12//!
13//! # Shape
14//!
15//! ```ignore
16//! use damascene_core::prelude::*;
17//!
18//! struct Workbench {
19//!     docs: Vec<String>,
20//!     active: String,
21//! }
22//!
23//! impl App for Workbench {
24//!     fn build(&self, _cx: &BuildCx) -> El {
25//!         column([
26//!             editor_tabs(
27//!                 "docs",
28//!                 &self.active,
29//!                 self.docs.iter().map(|d| (d.clone(), d.clone())),
30//!             ),
31//!             // panel for the active document...
32//!         ])
33//!     }
34//!
35//!     fn on_event(&mut self, event: UiEvent) {
36//!         let mut counter = 0;
37//!         editor_tabs::apply_event(
38//!             &mut self.docs,
39//!             &mut self.active,
40//!             &event,
41//!             "docs",
42//!             |s| Some(s.to_string()),
43//!             || {
44//!                 counter += 1;
45//!                 format!("doc-{counter}")
46//!             },
47//!         );
48//!     }
49//! }
50//! ```
51//!
52//! # Routed keys
53//!
54//! - `{key}:tab:{value}` — `Click` on a tab body activates it;
55//!   `MiddleClick` closes it. The token format matches
56//!   [`crate::widgets::tabs`] so the same per-app conventions apply.
57//! - `{key}:close:{value}` — `Click` on a tab's `×`; the app removes
58//!   that document and (if it was active) picks a neighbour.
59//! - `{key}:add` — `Click` on the trailing `+`; the app appends a
60//!   new tab and activates it.
61//!
62//! # Configuration
63//!
64//! Default flavor matches VS Code: lifted active tab, close icon at
65//! full opacity on the active tab and dimmed on the rest. Override
66//! via [`editor_tabs_with`] + [`EditorTabsConfig`] for top-accent
67//! (Chrome-like) or always-visible close icons.
68//!
69//! # Dogfood note
70//!
71//! Composes only the public widget-kit surface — `Kind::Custom` for
72//! the inspector tag, `.focusable()` + `.paint_overflow()` for the
73//! focus ring on each tab, `.key()` for hit-test routing, and
74//! [`crate::widgets::button::icon_button`] (with `.ghost()`) for the
75//! close + add affordances. An app crate can fork this file. See
76//! `widget_kit.md`.
77
78use std::panic::Location;
79
80use crate::cursor::Cursor;
81use crate::event::{UiEvent, UiEventKind};
82use crate::style::StyleProfile;
83use crate::tokens;
84use crate::tree::*;
85use crate::widgets::button::icon_button;
86use crate::{IconName, text};
87
88/// Visual treatment for the active tab.
89///
90/// `Lifted` is the default — it matches VS Code, Sublime, and most
91/// modern editor tab strips: the active tab fills with [`tokens::CARD`]
92/// so it visually attaches to whatever panel sits below it. `TopAccent`
93/// is the Chrome-style treatment (a coloured rule sits above the active
94/// tab); `BottomRule` is the Material-style rule under the active tab.
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
96#[non_exhaustive]
97pub enum ActiveTabStyle {
98    /// VS Code: active tab fills with `CARD`, inactive tabs are
99    /// transparent over the strip's `MUTED` background.
100    #[default]
101    Lifted,
102    /// Chrome-ish: a 2 px [`tokens::PRIMARY`] rule sits above the
103    /// active tab; tab fills stay uniform across active and inactive.
104    TopAccent,
105    /// Material: a 2 px [`tokens::PRIMARY`] rule sits below the active
106    /// tab.
107    BottomRule,
108}
109
110/// When the close (`×`) icon is rendered on each tab.
111///
112/// All three variants keep the close icon in the tab layout so the
113/// tab geometry stays stable across selection. They differ only in
114/// the rest-state opacity: `ActiveOrHover` hides it entirely until a
115/// hover signal arrives, `Dimmed` keeps a faint hint, and `Always`
116/// shows it unconditionally.
117///
118/// The hover signal cascades from the tab through
119/// [`crate::tree::El::hover_alpha`] — when the user mouses over the
120/// tab (or directly over the `×`), the icon eases up to full opacity
121/// via the runtime's subtree interaction envelope. Keyboard focus on
122/// the tab also reveals the icon, so a tabbed-into inactive tab still
123/// shows its close affordance.
124#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
125#[non_exhaustive]
126pub enum CloseVisibility {
127    /// VS Code default: full opacity on the active tab, invisible on
128    /// inactive tabs at rest, eased up to full on hover (of either
129    /// the tab body or the `×` itself).
130    #[default]
131    ActiveOrHover,
132    /// Always at full opacity. Matches Antd `editable-card` tabs.
133    Always,
134    /// Always visible but de-emphasized on inactive non-hovered tabs
135    /// (rest at 40% opacity), brightening to full on hover. A softer
136    /// "always discoverable" variant.
137    Dimmed,
138}
139
140/// Configuration for [`editor_tabs_with`]. Public-fields struct so
141/// callers can spread `..Default::default()` to override one field
142/// at a time.
143#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
144pub struct EditorTabsConfig {
145    pub active_style: ActiveTabStyle,
146    pub close_visibility: CloseVisibility,
147}
148
149/// What a routed [`UiEvent`] means for an editor-tabs strip keyed
150/// `key`.
151///
152/// Returned by [`classify_event`]; [`apply_event`] is the convenience
153/// wrapper that applies the action to the app's `(tabs, active)` pair.
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155#[non_exhaustive]
156pub enum EditorTabsAction<'a> {
157    /// A tab body was clicked. Activate this tab.
158    Select(&'a str),
159    /// A tab's `×` was clicked, or a tab body was middle-clicked.
160    /// Remove this tab from the list and, if it was active, pick a
161    /// neighbour.
162    Close(&'a str),
163    /// The trailing `+` button was clicked. Append a new tab and
164    /// activate it.
165    Add,
166}
167
168/// Format the routed key emitted when a tab body is clicked. Mirrors
169/// [`crate::widgets::tabs::tab_option_key`] so apps that already use
170/// `tab_option_key` for [`tabs_list`][crate::widgets::tabs::tabs_list]
171/// can reuse the same helper.
172pub fn editor_tab_select_key(key: &str, value: &impl std::fmt::Display) -> String {
173    format!("{key}:tab:{value}")
174}
175
176/// Format the routed key emitted when a tab's `×` is clicked.
177pub fn editor_tab_close_key(key: &str, value: &impl std::fmt::Display) -> String {
178    format!("{key}:close:{value}")
179}
180
181/// Format the routed key emitted when the trailing `+` is clicked.
182pub fn editor_tab_add_key(key: &str) -> String {
183    format!("{key}:add")
184}
185
186/// Classify a routed [`UiEvent`] against an editor-tabs strip keyed
187/// `key`. Returns `None` for events that aren't for this strip.
188///
189/// `Click` / `Activate` qualify for normal tab-strip actions.
190/// `MiddleClick` qualifies only on tab and close routes, mapping to
191/// [`EditorTabsAction::Close`] so editor tabs follow the common
192/// browser / editor convention. The borrowed string in
193/// [`EditorTabsAction::Select`] / [`EditorTabsAction::Close`] points
194/// into the event's routed key, so apps that want to keep the value
195/// beyond the match arm should `.to_string()` or `.parse()` it inline.
196pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<EditorTabsAction<'a>> {
197    if !matches!(
198        event.kind,
199        UiEventKind::Click | UiEventKind::Activate | UiEventKind::MiddleClick
200    ) {
201        return None;
202    }
203    let routed = event.route()?;
204    let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
205    if event.kind == UiEventKind::MiddleClick {
206        if let Some(value) = rest
207            .strip_prefix("tab:")
208            .or_else(|| rest.strip_prefix("close:"))
209        {
210            return Some(EditorTabsAction::Close(value));
211        }
212        return None;
213    }
214    if let Some(value) = rest.strip_prefix("tab:") {
215        return Some(EditorTabsAction::Select(value));
216    }
217    if let Some(value) = rest.strip_prefix("close:") {
218        return Some(EditorTabsAction::Close(value));
219    }
220    if rest == "add" {
221        return Some(EditorTabsAction::Add);
222    }
223    None
224}
225
226/// Fold a routed [`UiEvent`] into the app's `(tabs, active)` state for
227/// an editor-tabs strip keyed `key`. Returns `true` if the event was
228/// for this strip (so the caller can short-circuit further dispatch),
229/// `false` otherwise.
230///
231/// `parse` converts the raw value token back to the app's value type.
232/// `mint_new` produces a fresh value when the user clicks `+`. The
233/// helper handles three cases:
234///
235/// - **Select** — sets `active` to the parsed value.
236/// - **Close** — removes the matching entry from `tabs`. If the
237///   closed tab was active, `active` shifts to the neighbour at the
238///   same index (or the previous one when closing the last tab); the
239///   list is left untouched if the parsed value is no longer present.
240///   The last-remaining tab can't be closed via this helper — apps
241///   that want to allow that must handle [`EditorTabsAction::Close`]
242///   directly so they can decide what `active` becomes.
243/// - **Add** — appends `mint_new()` and activates it.
244///
245/// Apps that need finer control (e.g. confirmation prompts before
246/// closing a dirty tab, or closing the last tab) should call
247/// [`classify_event`] and handle each action themselves.
248pub fn apply_event<V>(
249    tabs: &mut Vec<V>,
250    active: &mut V,
251    event: &UiEvent,
252    key: &str,
253    parse: impl Fn(&str) -> Option<V>,
254    mint_new: impl FnOnce() -> V,
255) -> bool
256where
257    V: Clone + PartialEq,
258{
259    match classify_event(event, key) {
260        Some(EditorTabsAction::Select(raw)) => {
261            if let Some(v) = parse(raw) {
262                *active = v;
263            }
264            true
265        }
266        Some(EditorTabsAction::Close(raw)) => {
267            let Some(target) = parse(raw) else {
268                return true;
269            };
270            let Some(index) = tabs.iter().position(|t| *t == target) else {
271                return true;
272            };
273            // Refuse to close the last tab — leaves `active` pointing
274            // at a non-existent value otherwise. Apps that want to
275            // allow it should handle Close directly.
276            if tabs.len() <= 1 {
277                return true;
278            }
279            let was_active = *active == target;
280            tabs.remove(index);
281            if was_active {
282                let next = index.min(tabs.len() - 1);
283                *active = tabs[next].clone();
284            }
285            true
286        }
287        Some(EditorTabsAction::Add) => {
288            let new = mint_new();
289            *active = new.clone();
290            tabs.push(new);
291            true
292        }
293        None => false,
294    }
295}
296
297/// The trigger for one tab inside an [`editor_tabs`] strip. Apps
298/// usually let `editor_tabs` build these from its options iterator;
299/// reach for `editor_tab` directly when composing the strip by hand
300/// (e.g. mixing in icons, modified-dot indicators, or per-tab tooltips
301/// the wrapper doesn't expose).
302///
303/// `strip_key` is the parent strip's key — the routed keys on the
304/// resulting element are `{strip_key}:tab:{value}` (whole tab) and
305/// `{strip_key}:close:{value}` (the `×`). `selected` styles the tab
306/// as active.
307///
308/// `leading` is an optional element placed inside the tab body before
309/// the label — typically a small status indicator (CI dot, modified
310/// mark, brand glyph) that should sit inside the tab and inherit its
311/// hover / focus envelope. Pass `None` for the plain label-only shape.
312#[track_caller]
313pub fn editor_tab(
314    strip_key: &str,
315    value: impl std::fmt::Display,
316    leading: Option<El>,
317    label: impl Into<String>,
318    selected: bool,
319    config: EditorTabsConfig,
320) -> El {
321    let select_key = editor_tab_select_key(strip_key, &value);
322    let close_key = editor_tab_close_key(strip_key, &value);
323
324    let label_el = text(label).label().ellipsis().text_color(if selected {
325        tokens::FOREGROUND
326    } else {
327        tokens::MUTED_FOREGROUND
328    });
329
330    // The close icon is always present in the layout so tab geometry
331    // stays stable across selection. The active tab paints it at full
332    // opacity; inactive tabs use `hover_alpha(rest, 1.0)` so the icon
333    // eases between its rest opacity and full as the tab is hovered,
334    // pressed, or keyboard-focused.
335    let mut close = icon_button(IconName::X)
336        .key(close_key)
337        .icon_size(tokens::ICON_XS)
338        .ghost()
339        .width(Size::Fixed(tokens::SPACE_5))
340        .height(Size::Fixed(tokens::SPACE_5));
341    if !selected {
342        let rest = match config.close_visibility {
343            CloseVisibility::ActiveOrHover => 0.0,
344            CloseVisibility::Dimmed => 0.4,
345            CloseVisibility::Always => 1.0,
346        };
347        // Only attach the modifier when it would do something (rest <
348        // 1.0). At 1.0 the modifier is a no-op; skipping it keeps
349        // tree dumps for the `Always` flavor uncluttered.
350        if rest < 1.0 {
351            close = close.hover_alpha(rest, 1.0);
352        }
353    }
354
355    let mut body_children: Vec<El> = Vec::with_capacity(3);
356    if let Some(leading) = leading {
357        body_children.push(leading);
358    }
359    body_children.push(label_el);
360    body_children.push(close);
361    let body = row(body_children)
362        .gap(tokens::SPACE_2)
363        .align(Align::Center)
364        .padding(Sides::xy(tokens::SPACE_3, 0.0))
365        .height(Size::Fill(1.0));
366
367    // The accent rule is a fixed 2 px row above or below the body
368    // (depending on `active_style`). Always rendered so the tab keeps
369    // a stable height across selection changes; the colour is
370    // unset on inactive tabs (no fill draw).
371    let rule = || {
372        let mut el = El::new(Kind::Custom("editor_tab_accent_rule"))
373            .height(Size::Fixed(2.0))
374            .width(Size::Fill(1.0));
375        if selected {
376            el = el.fill(tokens::PRIMARY);
377        }
378        el
379    };
380
381    let stack = match config.active_style {
382        ActiveTabStyle::Lifted => column([body]),
383        ActiveTabStyle::TopAccent => column([rule(), body]),
384        ActiveTabStyle::BottomRule => column([body, rule()]),
385    };
386
387    let mut tab = stack
388        .at_loc(Location::caller())
389        .key(select_key)
390        .style_profile(StyleProfile::Solid)
391        .focusable()
392        .cursor(Cursor::Pointer)
393        .paint_overflow(Sides::all(tokens::RING_WIDTH))
394        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
395        .axis(Axis::Column)
396        .align(Align::Stretch)
397        .height(Size::Fixed(tokens::CONTROL_HEIGHT + 2.0))
398        .width(Size::Hug);
399    if matches!(config.active_style, ActiveTabStyle::Lifted) && selected {
400        tab = tab.fill(tokens::CARD).default_radius(tokens::RADIUS_SM);
401    }
402    tab
403}
404
405/// An editor-tab strip with default config (lifted active tab, dimmed
406/// close icons on inactive tabs). See [`editor_tabs_with`] for
407/// flavor overrides.
408#[track_caller]
409pub fn editor_tabs<I, V, L>(
410    key: impl Into<String>,
411    current: &impl std::fmt::Display,
412    options: I,
413) -> El
414where
415    I: IntoIterator<Item = (V, L)>,
416    V: std::fmt::Display,
417    L: Into<String>,
418{
419    editor_tabs_with(key, current, options, EditorTabsConfig::default())
420}
421
422/// An editor-tab strip with explicit configuration. Like [`editor_tabs`]
423/// but lets the caller pick the active-tab treatment and close-icon
424/// visibility.
425#[track_caller]
426pub fn editor_tabs_with<I, V, L>(
427    key: impl Into<String>,
428    current: &impl std::fmt::Display,
429    options: I,
430    config: EditorTabsConfig,
431) -> El
432where
433    I: IntoIterator<Item = (V, L)>,
434    V: std::fmt::Display,
435    L: Into<String>,
436{
437    let caller = Location::caller();
438    let key = key.into();
439    let current_str = current.to_string();
440
441    let mut children: Vec<El> = options
442        .into_iter()
443        .map(|(value, label)| {
444            let selected = value.to_string() == current_str;
445            editor_tab(&key, value, None, label, selected, config).at_loc(caller)
446        })
447        .collect();
448
449    // Trailing `+` button — separated from the last tab by a small
450    // gap so it reads as a distinct "new tab" affordance rather than
451    // another tab. Ghosted (no fill, no stroke) to match the strip's
452    // flat aesthetic.
453    let add_key = editor_tab_add_key(&key);
454    let add_btn = icon_button(IconName::Plus)
455        .at_loc(caller)
456        .key(add_key)
457        .icon_size(tokens::ICON_SM)
458        .ghost()
459        .width(Size::Fixed(tokens::CONTROL_HEIGHT))
460        .height(Size::Fixed(tokens::CONTROL_HEIGHT));
461    children.push(add_btn);
462
463    El::new(Kind::Custom("editor_tabs"))
464        .at_loc(caller)
465        .axis(Axis::Row)
466        .default_gap(tokens::SPACE_1)
467        .align(Align::Center)
468        .children(children)
469        .fill(tokens::MUTED)
470        .default_padding(Sides::xy(tokens::SPACE_2, tokens::SPACE_1))
471        .width(Size::Fill(1.0))
472        .height(Size::Hug)
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::event::KeyModifiers;
479
480    fn click(key: &str) -> UiEvent {
481        UiEvent {
482            path: None,
483            kind: UiEventKind::Click,
484            key: Some(key.to_string()),
485            target: None,
486            pointer: None,
487            key_press: None,
488            text: None,
489            selection: None,
490            modifiers: KeyModifiers::default(),
491            click_count: 1,
492            pointer_kind: None,
493            wheel_delta: None,
494        }
495    }
496
497    fn middle_click(key: &str) -> UiEvent {
498        let mut event = click(key);
499        event.kind = UiEventKind::MiddleClick;
500        event
501    }
502
503    #[test]
504    fn key_helpers_match_widget_format() {
505        assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
506        assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
507        assert_eq!(editor_tab_add_key("docs"), "docs:add");
508    }
509
510    #[test]
511    fn classify_event_recognises_all_three_actions() {
512        assert_eq!(
513            classify_event(&click("docs:tab:readme"), "docs"),
514            Some(EditorTabsAction::Select("readme")),
515        );
516        assert_eq!(
517            classify_event(&click("docs:close:readme"), "docs"),
518            Some(EditorTabsAction::Close("readme")),
519        );
520        assert_eq!(
521            classify_event(&click("docs:add"), "docs"),
522            Some(EditorTabsAction::Add),
523        );
524        // Non-matching keys fall through.
525        assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
526        assert_eq!(classify_event(&click("docs"), "docs"), None);
527    }
528
529    #[test]
530    fn classify_event_middle_click_on_tab_closes_it() {
531        assert_eq!(
532            classify_event(&middle_click("docs:tab:readme"), "docs"),
533            Some(EditorTabsAction::Close("readme")),
534        );
535        assert_eq!(
536            classify_event(&middle_click("docs:close:readme"), "docs"),
537            Some(EditorTabsAction::Close("readme")),
538        );
539        assert_eq!(
540            classify_event(&middle_click("docs:add"), "docs"),
541            None,
542            "middle-clicking the add button should not create a tab",
543        );
544    }
545
546    #[test]
547    fn classify_event_ignores_non_activating_kinds() {
548        let mut ev = click("docs:close:readme");
549        ev.kind = UiEventKind::PointerDown;
550        assert_eq!(classify_event(&ev, "docs"), None);
551        ev.kind = UiEventKind::Activate;
552        assert_eq!(
553            classify_event(&ev, "docs"),
554            Some(EditorTabsAction::Close("readme")),
555            "keyboard activation should fire close like a click",
556        );
557    }
558
559    #[test]
560    fn editor_tab_routes_via_select_key() {
561        let tab = editor_tab(
562            "docs",
563            "readme",
564            None,
565            "README.md",
566            false,
567            EditorTabsConfig::default(),
568        );
569        assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
570        assert!(tab.focusable);
571    }
572
573    #[test]
574    fn editor_tab_active_lifted_fills_with_card() {
575        let active = editor_tab(
576            "docs",
577            "readme",
578            None,
579            "README.md",
580            true,
581            EditorTabsConfig::default(),
582        );
583        let inactive = editor_tab(
584            "docs",
585            "readme",
586            None,
587            "README.md",
588            false,
589            EditorTabsConfig::default(),
590        );
591        assert_eq!(active.fill, Some(tokens::CARD));
592        assert_eq!(
593            inactive.fill, None,
594            "inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
595        );
596    }
597
598    #[test]
599    fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
600        let cfg = EditorTabsConfig {
601            active_style: ActiveTabStyle::TopAccent,
602            ..Default::default()
603        };
604        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
605        // Column with [rule, body]; the rule is the first child and
606        // carries the PRIMARY fill on the active tab.
607        assert!(active.children.len() >= 2);
608        assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
609    }
610
611    #[test]
612    fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
613        let cfg = EditorTabsConfig {
614            active_style: ActiveTabStyle::BottomRule,
615            ..Default::default()
616        };
617        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
618        let last = active.children.last().expect("at least one child");
619        assert_eq!(last.fill, Some(tokens::PRIMARY));
620    }
621
622    #[test]
623    fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
624        let cfg = EditorTabsConfig {
625            active_style: ActiveTabStyle::TopAccent,
626            ..Default::default()
627        };
628        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
629        // Rule row is still present so the tab's height stays stable
630        // across selection changes, but its fill is unset.
631        assert_eq!(inactive.children[0].fill, None);
632    }
633
634    #[test]
635    fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
636        let cfg = EditorTabsConfig {
637            close_visibility: CloseVisibility::ActiveOrHover,
638            ..Default::default()
639        };
640        // Each tab is `column([body])` (Lifted); body is the first
641        // child, which is a row of [label, close]. The close icon is
642        // always present in the layout — only its rest opacity changes
643        // — so geometry stays stable across selection.
644        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
645        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
646        let active_body = &active.children[0];
647        let inactive_body = &inactive.children[0];
648        assert_eq!(active_body.children.len(), 2);
649        assert_eq!(inactive_body.children.len(), 2);
650        // The active tab's close paints at full opacity (no modifier).
651        let active_close = &active_body.children[1];
652        assert_eq!(active_close.hover_alpha, None);
653        // The inactive tab's close is invisible at rest, fades in on
654        // hover / focus / press via the subtree interaction envelope.
655        let inactive_close = &inactive_body.children[1];
656        let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
657        assert_eq!(cfg.rest, 0.0);
658        assert_eq!(cfg.peak, 1.0);
659    }
660
661    #[test]
662    fn close_visibility_dimmed_uses_partial_rest_opacity() {
663        let cfg = EditorTabsConfig {
664            close_visibility: CloseVisibility::Dimmed,
665            ..Default::default()
666        };
667        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
668        let body = &inactive.children[0];
669        let close = &body.children[1];
670        // Dimmed sits between hidden and visible — close should rest
671        // around 0.4 alpha and ease up on hover.
672        match close.hover_alpha {
673            Some(cfg) => {
674                assert!(
675                    cfg.rest > 0.0 && cfg.rest < 1.0,
676                    "Dimmed rest should be partial; got {}",
677                    cfg.rest,
678                );
679                assert_eq!(cfg.peak, 1.0);
680            }
681            None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
682        }
683    }
684
685    #[test]
686    fn close_visibility_always_skips_hover_alpha() {
687        let cfg = EditorTabsConfig {
688            close_visibility: CloseVisibility::Always,
689            ..Default::default()
690        };
691        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
692        let body = &inactive.children[0];
693        let close = &body.children[1];
694        // `Always` is full opacity unconditionally — the modifier is a
695        // no-op at rest=1.0, so we skip attaching it to keep tree
696        // dumps for this flavor uncluttered.
697        assert_eq!(close.hover_alpha, None);
698    }
699
700    #[test]
701    fn editor_tab_leading_prepends_inside_the_body_row() {
702        let dot = crate::tree::column([crate::widgets::text::text("●")])
703            .width(Size::Fixed(8.0))
704            .height(Size::Fixed(8.0));
705        let tab = editor_tab(
706            "docs",
707            "readme",
708            Some(dot),
709            "README.md",
710            false,
711            EditorTabsConfig::default(),
712        );
713        // Outer is column([body]); body's children become
714        // [leading, label, close] when leading is Some.
715        let body = &tab.children[0];
716        assert_eq!(body.children.len(), 3);
717    }
718
719    #[test]
720    fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
721        let strip = editor_tabs(
722            "docs",
723            &"readme",
724            [("readme", "README.md"), ("main", "main.rs")],
725        );
726        // Two tabs + the trailing + button.
727        assert_eq!(strip.children.len(), 3);
728        let add = strip.children.last().unwrap();
729        assert_eq!(add.key.as_deref(), Some("docs:add"));
730    }
731
732    #[test]
733    fn editor_tabs_marks_only_the_current_value_active() {
734        let strip = editor_tabs(
735            "docs",
736            &"main",
737            [
738                ("readme", "README.md"),
739                ("main", "main.rs"),
740                ("cargo", "Cargo.toml"),
741            ],
742        );
743        assert_eq!(strip.children[0].fill, None);
744        assert_eq!(strip.children[1].fill, Some(tokens::CARD));
745        assert_eq!(strip.children[2].fill, None);
746    }
747
748    #[test]
749    fn apply_event_select_swaps_active_without_touching_tabs() {
750        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
751        let mut active = "a".to_string();
752        let next_id = || "fresh".to_string();
753        assert!(apply_event(
754            &mut tabs,
755            &mut active,
756            &click("docs:tab:b"),
757            "docs",
758            |s| Some(s.to_string()),
759            next_id,
760        ));
761        assert_eq!(active, "b");
762        assert_eq!(tabs, vec!["a", "b", "c"]);
763    }
764
765    #[test]
766    fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
767        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
768        let mut active = "b".to_string();
769        let next_id = || "fresh".to_string();
770        assert!(apply_event(
771            &mut tabs,
772            &mut active,
773            &click("docs:close:b"),
774            "docs",
775            |s| Some(s.to_string()),
776            next_id,
777        ));
778        assert_eq!(tabs, vec!["a", "c"]);
779        // The middle tab was active; closing it shifts to the same
780        // index, which is now "c".
781        assert_eq!(active, "c");
782    }
783
784    #[test]
785    fn apply_event_close_last_tab_picks_previous_neighbour() {
786        let mut tabs = vec!["a".to_string(), "b".to_string()];
787        let mut active = "b".to_string();
788        let next_id = || "fresh".to_string();
789        assert!(apply_event(
790            &mut tabs,
791            &mut active,
792            &click("docs:close:b"),
793            "docs",
794            |s| Some(s.to_string()),
795            next_id,
796        ));
797        assert_eq!(tabs, vec!["a"]);
798        assert_eq!(active, "a");
799    }
800
801    #[test]
802    fn apply_event_close_inactive_tab_leaves_active_alone() {
803        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
804        let mut active = "a".to_string();
805        let next_id = || "fresh".to_string();
806        assert!(apply_event(
807            &mut tabs,
808            &mut active,
809            &click("docs:close:c"),
810            "docs",
811            |s| Some(s.to_string()),
812            next_id,
813        ));
814        assert_eq!(tabs, vec!["a", "b"]);
815        assert_eq!(active, "a");
816    }
817
818    #[test]
819    fn apply_event_middle_click_on_tab_closes_it() {
820        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
821        let mut active = "a".to_string();
822        let next_id = || "fresh".to_string();
823        assert!(apply_event(
824            &mut tabs,
825            &mut active,
826            &middle_click("docs:tab:b"),
827            "docs",
828            |s| Some(s.to_string()),
829            next_id,
830        ));
831        assert_eq!(tabs, vec!["a", "c"]);
832        assert_eq!(active, "a");
833    }
834
835    #[test]
836    fn apply_event_refuses_to_close_the_last_tab() {
837        let mut tabs = vec!["a".to_string()];
838        let mut active = "a".to_string();
839        let next_id = || "fresh".to_string();
840        assert!(apply_event(
841            &mut tabs,
842            &mut active,
843            &click("docs:close:a"),
844            "docs",
845            |s| Some(s.to_string()),
846            next_id,
847        ));
848        assert_eq!(
849            tabs,
850            vec!["a"],
851            "the last tab can't be closed via the helper"
852        );
853        assert_eq!(active, "a");
854    }
855
856    #[test]
857    fn apply_event_add_appends_and_activates_a_minted_tab() {
858        let mut tabs = vec!["a".to_string()];
859        let mut active = "a".to_string();
860        let mut counter = 0;
861        let next_id = || {
862            counter += 1;
863            format!("new-{counter}")
864        };
865        assert!(apply_event(
866            &mut tabs,
867            &mut active,
868            &click("docs:add"),
869            "docs",
870            |s| Some(s.to_string()),
871            next_id,
872        ));
873        assert_eq!(tabs, vec!["a", "new-1"]);
874        assert_eq!(active, "new-1");
875    }
876
877    #[test]
878    fn apply_event_returns_false_for_foreign_events() {
879        let mut tabs = vec!["a".to_string()];
880        let mut active = "a".to_string();
881        let next_id = || "fresh".to_string();
882        assert!(!apply_event(
883            &mut tabs,
884            &mut active,
885            &click("save"),
886            "docs",
887            |s| Some(s.to_string()),
888            next_id,
889        ));
890        assert_eq!(tabs, vec!["a"]);
891        assert_eq!(active, "a");
892    }
893}