Skip to main content

aetna_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 aetna_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        }
493    }
494
495    fn middle_click(key: &str) -> UiEvent {
496        let mut event = click(key);
497        event.kind = UiEventKind::MiddleClick;
498        event
499    }
500
501    #[test]
502    fn key_helpers_match_widget_format() {
503        assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
504        assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
505        assert_eq!(editor_tab_add_key("docs"), "docs:add");
506    }
507
508    #[test]
509    fn classify_event_recognises_all_three_actions() {
510        assert_eq!(
511            classify_event(&click("docs:tab:readme"), "docs"),
512            Some(EditorTabsAction::Select("readme")),
513        );
514        assert_eq!(
515            classify_event(&click("docs:close:readme"), "docs"),
516            Some(EditorTabsAction::Close("readme")),
517        );
518        assert_eq!(
519            classify_event(&click("docs:add"), "docs"),
520            Some(EditorTabsAction::Add),
521        );
522        // Non-matching keys fall through.
523        assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
524        assert_eq!(classify_event(&click("docs"), "docs"), None);
525    }
526
527    #[test]
528    fn classify_event_middle_click_on_tab_closes_it() {
529        assert_eq!(
530            classify_event(&middle_click("docs:tab:readme"), "docs"),
531            Some(EditorTabsAction::Close("readme")),
532        );
533        assert_eq!(
534            classify_event(&middle_click("docs:close:readme"), "docs"),
535            Some(EditorTabsAction::Close("readme")),
536        );
537        assert_eq!(
538            classify_event(&middle_click("docs:add"), "docs"),
539            None,
540            "middle-clicking the add button should not create a tab",
541        );
542    }
543
544    #[test]
545    fn classify_event_ignores_non_activating_kinds() {
546        let mut ev = click("docs:close:readme");
547        ev.kind = UiEventKind::PointerDown;
548        assert_eq!(classify_event(&ev, "docs"), None);
549        ev.kind = UiEventKind::Activate;
550        assert_eq!(
551            classify_event(&ev, "docs"),
552            Some(EditorTabsAction::Close("readme")),
553            "keyboard activation should fire close like a click",
554        );
555    }
556
557    #[test]
558    fn editor_tab_routes_via_select_key() {
559        let tab = editor_tab(
560            "docs",
561            "readme",
562            None,
563            "README.md",
564            false,
565            EditorTabsConfig::default(),
566        );
567        assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
568        assert!(tab.focusable);
569    }
570
571    #[test]
572    fn editor_tab_active_lifted_fills_with_card() {
573        let active = editor_tab(
574            "docs",
575            "readme",
576            None,
577            "README.md",
578            true,
579            EditorTabsConfig::default(),
580        );
581        let inactive = editor_tab(
582            "docs",
583            "readme",
584            None,
585            "README.md",
586            false,
587            EditorTabsConfig::default(),
588        );
589        assert_eq!(active.fill, Some(tokens::CARD));
590        assert_eq!(
591            inactive.fill, None,
592            "inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
593        );
594    }
595
596    #[test]
597    fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
598        let cfg = EditorTabsConfig {
599            active_style: ActiveTabStyle::TopAccent,
600            ..Default::default()
601        };
602        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
603        // Column with [rule, body]; the rule is the first child and
604        // carries the PRIMARY fill on the active tab.
605        assert!(active.children.len() >= 2);
606        assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
607    }
608
609    #[test]
610    fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
611        let cfg = EditorTabsConfig {
612            active_style: ActiveTabStyle::BottomRule,
613            ..Default::default()
614        };
615        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
616        let last = active.children.last().expect("at least one child");
617        assert_eq!(last.fill, Some(tokens::PRIMARY));
618    }
619
620    #[test]
621    fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
622        let cfg = EditorTabsConfig {
623            active_style: ActiveTabStyle::TopAccent,
624            ..Default::default()
625        };
626        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
627        // Rule row is still present so the tab's height stays stable
628        // across selection changes, but its fill is unset.
629        assert_eq!(inactive.children[0].fill, None);
630    }
631
632    #[test]
633    fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
634        let cfg = EditorTabsConfig {
635            close_visibility: CloseVisibility::ActiveOrHover,
636            ..Default::default()
637        };
638        // Each tab is `column([body])` (Lifted); body is the first
639        // child, which is a row of [label, close]. The close icon is
640        // always present in the layout — only its rest opacity changes
641        // — so geometry stays stable across selection.
642        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
643        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
644        let active_body = &active.children[0];
645        let inactive_body = &inactive.children[0];
646        assert_eq!(active_body.children.len(), 2);
647        assert_eq!(inactive_body.children.len(), 2);
648        // The active tab's close paints at full opacity (no modifier).
649        let active_close = &active_body.children[1];
650        assert_eq!(active_close.hover_alpha, None);
651        // The inactive tab's close is invisible at rest, fades in on
652        // hover / focus / press via the subtree interaction envelope.
653        let inactive_close = &inactive_body.children[1];
654        let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
655        assert_eq!(cfg.rest, 0.0);
656        assert_eq!(cfg.peak, 1.0);
657    }
658
659    #[test]
660    fn close_visibility_dimmed_uses_partial_rest_opacity() {
661        let cfg = EditorTabsConfig {
662            close_visibility: CloseVisibility::Dimmed,
663            ..Default::default()
664        };
665        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
666        let body = &inactive.children[0];
667        let close = &body.children[1];
668        // Dimmed sits between hidden and visible — close should rest
669        // around 0.4 alpha and ease up on hover.
670        match close.hover_alpha {
671            Some(cfg) => {
672                assert!(
673                    cfg.rest > 0.0 && cfg.rest < 1.0,
674                    "Dimmed rest should be partial; got {}",
675                    cfg.rest,
676                );
677                assert_eq!(cfg.peak, 1.0);
678            }
679            None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
680        }
681    }
682
683    #[test]
684    fn close_visibility_always_skips_hover_alpha() {
685        let cfg = EditorTabsConfig {
686            close_visibility: CloseVisibility::Always,
687            ..Default::default()
688        };
689        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
690        let body = &inactive.children[0];
691        let close = &body.children[1];
692        // `Always` is full opacity unconditionally — the modifier is a
693        // no-op at rest=1.0, so we skip attaching it to keep tree
694        // dumps for this flavor uncluttered.
695        assert_eq!(close.hover_alpha, None);
696    }
697
698    #[test]
699    fn editor_tab_leading_prepends_inside_the_body_row() {
700        let dot = crate::tree::column([crate::widgets::text::text("●")])
701            .width(Size::Fixed(8.0))
702            .height(Size::Fixed(8.0));
703        let tab = editor_tab(
704            "docs",
705            "readme",
706            Some(dot),
707            "README.md",
708            false,
709            EditorTabsConfig::default(),
710        );
711        // Outer is column([body]); body's children become
712        // [leading, label, close] when leading is Some.
713        let body = &tab.children[0];
714        assert_eq!(body.children.len(), 3);
715    }
716
717    #[test]
718    fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
719        let strip = editor_tabs(
720            "docs",
721            &"readme",
722            [("readme", "README.md"), ("main", "main.rs")],
723        );
724        // Two tabs + the trailing + button.
725        assert_eq!(strip.children.len(), 3);
726        let add = strip.children.last().unwrap();
727        assert_eq!(add.key.as_deref(), Some("docs:add"));
728    }
729
730    #[test]
731    fn editor_tabs_marks_only_the_current_value_active() {
732        let strip = editor_tabs(
733            "docs",
734            &"main",
735            [
736                ("readme", "README.md"),
737                ("main", "main.rs"),
738                ("cargo", "Cargo.toml"),
739            ],
740        );
741        assert_eq!(strip.children[0].fill, None);
742        assert_eq!(strip.children[1].fill, Some(tokens::CARD));
743        assert_eq!(strip.children[2].fill, None);
744    }
745
746    #[test]
747    fn apply_event_select_swaps_active_without_touching_tabs() {
748        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
749        let mut active = "a".to_string();
750        let next_id = || "fresh".to_string();
751        assert!(apply_event(
752            &mut tabs,
753            &mut active,
754            &click("docs:tab:b"),
755            "docs",
756            |s| Some(s.to_string()),
757            next_id,
758        ));
759        assert_eq!(active, "b");
760        assert_eq!(tabs, vec!["a", "b", "c"]);
761    }
762
763    #[test]
764    fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
765        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
766        let mut active = "b".to_string();
767        let next_id = || "fresh".to_string();
768        assert!(apply_event(
769            &mut tabs,
770            &mut active,
771            &click("docs:close:b"),
772            "docs",
773            |s| Some(s.to_string()),
774            next_id,
775        ));
776        assert_eq!(tabs, vec!["a", "c"]);
777        // The middle tab was active; closing it shifts to the same
778        // index, which is now "c".
779        assert_eq!(active, "c");
780    }
781
782    #[test]
783    fn apply_event_close_last_tab_picks_previous_neighbour() {
784        let mut tabs = vec!["a".to_string(), "b".to_string()];
785        let mut active = "b".to_string();
786        let next_id = || "fresh".to_string();
787        assert!(apply_event(
788            &mut tabs,
789            &mut active,
790            &click("docs:close:b"),
791            "docs",
792            |s| Some(s.to_string()),
793            next_id,
794        ));
795        assert_eq!(tabs, vec!["a"]);
796        assert_eq!(active, "a");
797    }
798
799    #[test]
800    fn apply_event_close_inactive_tab_leaves_active_alone() {
801        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
802        let mut active = "a".to_string();
803        let next_id = || "fresh".to_string();
804        assert!(apply_event(
805            &mut tabs,
806            &mut active,
807            &click("docs:close:c"),
808            "docs",
809            |s| Some(s.to_string()),
810            next_id,
811        ));
812        assert_eq!(tabs, vec!["a", "b"]);
813        assert_eq!(active, "a");
814    }
815
816    #[test]
817    fn apply_event_middle_click_on_tab_closes_it() {
818        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
819        let mut active = "a".to_string();
820        let next_id = || "fresh".to_string();
821        assert!(apply_event(
822            &mut tabs,
823            &mut active,
824            &middle_click("docs:tab:b"),
825            "docs",
826            |s| Some(s.to_string()),
827            next_id,
828        ));
829        assert_eq!(tabs, vec!["a", "c"]);
830        assert_eq!(active, "a");
831    }
832
833    #[test]
834    fn apply_event_refuses_to_close_the_last_tab() {
835        let mut tabs = vec!["a".to_string()];
836        let mut active = "a".to_string();
837        let next_id = || "fresh".to_string();
838        assert!(apply_event(
839            &mut tabs,
840            &mut active,
841            &click("docs:close:a"),
842            "docs",
843            |s| Some(s.to_string()),
844            next_id,
845        ));
846        assert_eq!(
847            tabs,
848            vec!["a"],
849            "the last tab can't be closed via the helper"
850        );
851        assert_eq!(active, "a");
852    }
853
854    #[test]
855    fn apply_event_add_appends_and_activates_a_minted_tab() {
856        let mut tabs = vec!["a".to_string()];
857        let mut active = "a".to_string();
858        let mut counter = 0;
859        let next_id = || {
860            counter += 1;
861            format!("new-{counter}")
862        };
863        assert!(apply_event(
864            &mut tabs,
865            &mut active,
866            &click("docs:add"),
867            "docs",
868            |s| Some(s.to_string()),
869            next_id,
870        ));
871        assert_eq!(tabs, vec!["a", "new-1"]);
872        assert_eq!(active, "new-1");
873    }
874
875    #[test]
876    fn apply_event_returns_false_for_foreign_events() {
877        let mut tabs = vec!["a".to_string()];
878        let mut active = "a".to_string();
879        let next_id = || "fresh".to_string();
880        assert!(!apply_event(
881            &mut tabs,
882            &mut active,
883            &click("save"),
884            "docs",
885            |s| Some(s.to_string()),
886            next_id,
887        ));
888        assert_eq!(tabs, vec!["a"]);
889        assert_eq!(active, "a");
890    }
891}