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; the app sets the
55//!   active tab. The token format matches [`crate::widgets::tabs`]
56//!   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. Remove this tab from the list and,
160    /// if it was active, pick a neighbour.
161    Close(&'a str),
162    /// The trailing `+` button was clicked. Append a new tab and
163    /// activate it.
164    Add,
165}
166
167/// Format the routed key emitted when a tab body is clicked. Mirrors
168/// [`crate::widgets::tabs::tab_option_key`] so apps that already use
169/// `tab_option_key` for [`tabs_list`][crate::widgets::tabs::tabs_list]
170/// can reuse the same helper.
171pub fn editor_tab_select_key(key: &str, value: &impl std::fmt::Display) -> String {
172    format!("{key}:tab:{value}")
173}
174
175/// Format the routed key emitted when a tab's `×` is clicked.
176pub fn editor_tab_close_key(key: &str, value: &impl std::fmt::Display) -> String {
177    format!("{key}:close:{value}")
178}
179
180/// Format the routed key emitted when the trailing `+` is clicked.
181pub fn editor_tab_add_key(key: &str) -> String {
182    format!("{key}:add")
183}
184
185/// Classify a routed [`UiEvent`] against an editor-tabs strip keyed
186/// `key`. Returns `None` for events that aren't for this strip.
187///
188/// Only `Click` / `Activate` event kinds qualify. The borrowed string
189/// in [`EditorTabsAction::Select`] / [`EditorTabsAction::Close`]
190/// points into the event's routed key, so apps that want to keep the
191/// value beyond the match arm should `.to_string()` or `.parse()` it
192/// inline.
193pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<EditorTabsAction<'a>> {
194    if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
195        return None;
196    }
197    let routed = event.route()?;
198    let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
199    if let Some(value) = rest.strip_prefix("tab:") {
200        return Some(EditorTabsAction::Select(value));
201    }
202    if let Some(value) = rest.strip_prefix("close:") {
203        return Some(EditorTabsAction::Close(value));
204    }
205    if rest == "add" {
206        return Some(EditorTabsAction::Add);
207    }
208    None
209}
210
211/// Fold a routed [`UiEvent`] into the app's `(tabs, active)` state for
212/// an editor-tabs strip keyed `key`. Returns `true` if the event was
213/// for this strip (so the caller can short-circuit further dispatch),
214/// `false` otherwise.
215///
216/// `parse` converts the raw value token back to the app's value type.
217/// `mint_new` produces a fresh value when the user clicks `+`. The
218/// helper handles three cases:
219///
220/// - **Select** — sets `active` to the parsed value.
221/// - **Close** — removes the matching entry from `tabs`. If the
222///   closed tab was active, `active` shifts to the neighbour at the
223///   same index (or the previous one when closing the last tab); the
224///   list is left untouched if the parsed value is no longer present.
225///   The last-remaining tab can't be closed via this helper — apps
226///   that want to allow that must handle [`EditorTabsAction::Close`]
227///   directly so they can decide what `active` becomes.
228/// - **Add** — appends `mint_new()` and activates it.
229///
230/// Apps that need finer control (e.g. confirmation prompts before
231/// closing a dirty tab, or closing the last tab) should call
232/// [`classify_event`] and handle each action themselves.
233pub fn apply_event<V>(
234    tabs: &mut Vec<V>,
235    active: &mut V,
236    event: &UiEvent,
237    key: &str,
238    parse: impl Fn(&str) -> Option<V>,
239    mint_new: impl FnOnce() -> V,
240) -> bool
241where
242    V: Clone + PartialEq,
243{
244    match classify_event(event, key) {
245        Some(EditorTabsAction::Select(raw)) => {
246            if let Some(v) = parse(raw) {
247                *active = v;
248            }
249            true
250        }
251        Some(EditorTabsAction::Close(raw)) => {
252            let Some(target) = parse(raw) else {
253                return true;
254            };
255            let Some(index) = tabs.iter().position(|t| *t == target) else {
256                return true;
257            };
258            // Refuse to close the last tab — leaves `active` pointing
259            // at a non-existent value otherwise. Apps that want to
260            // allow it should handle Close directly.
261            if tabs.len() <= 1 {
262                return true;
263            }
264            let was_active = *active == target;
265            tabs.remove(index);
266            if was_active {
267                let next = index.min(tabs.len() - 1);
268                *active = tabs[next].clone();
269            }
270            true
271        }
272        Some(EditorTabsAction::Add) => {
273            let new = mint_new();
274            *active = new.clone();
275            tabs.push(new);
276            true
277        }
278        None => false,
279    }
280}
281
282/// The trigger for one tab inside an [`editor_tabs`] strip. Apps
283/// usually let `editor_tabs` build these from its options iterator;
284/// reach for `editor_tab` directly when composing the strip by hand
285/// (e.g. mixing in icons, modified-dot indicators, or per-tab tooltips
286/// the wrapper doesn't expose).
287///
288/// `strip_key` is the parent strip's key — the routed keys on the
289/// resulting element are `{strip_key}:tab:{value}` (whole tab) and
290/// `{strip_key}:close:{value}` (the `×`). `selected` styles the tab
291/// as active.
292///
293/// `leading` is an optional element placed inside the tab body before
294/// the label — typically a small status indicator (CI dot, modified
295/// mark, brand glyph) that should sit inside the tab and inherit its
296/// hover / focus envelope. Pass `None` for the plain label-only shape.
297#[track_caller]
298pub fn editor_tab(
299    strip_key: &str,
300    value: impl std::fmt::Display,
301    leading: Option<El>,
302    label: impl Into<String>,
303    selected: bool,
304    config: EditorTabsConfig,
305) -> El {
306    let select_key = editor_tab_select_key(strip_key, &value);
307    let close_key = editor_tab_close_key(strip_key, &value);
308
309    let label_el = text(label).label().ellipsis().text_color(if selected {
310        tokens::FOREGROUND
311    } else {
312        tokens::MUTED_FOREGROUND
313    });
314
315    // The close icon is always present in the layout so tab geometry
316    // stays stable across selection. The active tab paints it at full
317    // opacity; inactive tabs use `hover_alpha(rest, 1.0)` so the icon
318    // eases between its rest opacity and full as the tab is hovered,
319    // pressed, or keyboard-focused.
320    let mut close = icon_button(IconName::X)
321        .key(close_key)
322        .icon_size(tokens::ICON_XS)
323        .ghost()
324        .width(Size::Fixed(tokens::SPACE_5))
325        .height(Size::Fixed(tokens::SPACE_5));
326    if !selected {
327        let rest = match config.close_visibility {
328            CloseVisibility::ActiveOrHover => 0.0,
329            CloseVisibility::Dimmed => 0.4,
330            CloseVisibility::Always => 1.0,
331        };
332        // Only attach the modifier when it would do something (rest <
333        // 1.0). At 1.0 the modifier is a no-op; skipping it keeps
334        // tree dumps for the `Always` flavor uncluttered.
335        if rest < 1.0 {
336            close = close.hover_alpha(rest, 1.0);
337        }
338    }
339
340    let mut body_children: Vec<El> = Vec::with_capacity(3);
341    if let Some(leading) = leading {
342        body_children.push(leading);
343    }
344    body_children.push(label_el);
345    body_children.push(close);
346    let body = row(body_children)
347        .gap(tokens::SPACE_2)
348        .align(Align::Center)
349        .padding(Sides::xy(tokens::SPACE_3, 0.0))
350        .height(Size::Fill(1.0));
351
352    // The accent rule is a fixed 2 px row above or below the body
353    // (depending on `active_style`). Always rendered so the tab keeps
354    // a stable height across selection changes; the colour is
355    // unset on inactive tabs (no fill draw).
356    let rule = || {
357        let mut el = El::new(Kind::Custom("editor_tab_accent_rule"))
358            .height(Size::Fixed(2.0))
359            .width(Size::Fill(1.0));
360        if selected {
361            el = el.fill(tokens::PRIMARY);
362        }
363        el
364    };
365
366    let stack = match config.active_style {
367        ActiveTabStyle::Lifted => column([body]),
368        ActiveTabStyle::TopAccent => column([rule(), body]),
369        ActiveTabStyle::BottomRule => column([body, rule()]),
370    };
371
372    let mut tab = stack
373        .at_loc(Location::caller())
374        .key(select_key)
375        .style_profile(StyleProfile::Solid)
376        .focusable()
377        .cursor(Cursor::Pointer)
378        .paint_overflow(Sides::all(tokens::RING_WIDTH))
379        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
380        .axis(Axis::Column)
381        .align(Align::Stretch)
382        .height(Size::Fixed(tokens::CONTROL_HEIGHT + 2.0))
383        .width(Size::Hug);
384    if matches!(config.active_style, ActiveTabStyle::Lifted) && selected {
385        tab = tab.fill(tokens::CARD).default_radius(tokens::RADIUS_SM);
386    }
387    tab
388}
389
390/// An editor-tab strip with default config (lifted active tab, dimmed
391/// close icons on inactive tabs). See [`editor_tabs_with`] for
392/// flavor overrides.
393#[track_caller]
394pub fn editor_tabs<I, V, L>(
395    key: impl Into<String>,
396    current: &impl std::fmt::Display,
397    options: I,
398) -> El
399where
400    I: IntoIterator<Item = (V, L)>,
401    V: std::fmt::Display,
402    L: Into<String>,
403{
404    editor_tabs_with(key, current, options, EditorTabsConfig::default())
405}
406
407/// An editor-tab strip with explicit configuration. Like [`editor_tabs`]
408/// but lets the caller pick the active-tab treatment and close-icon
409/// visibility.
410#[track_caller]
411pub fn editor_tabs_with<I, V, L>(
412    key: impl Into<String>,
413    current: &impl std::fmt::Display,
414    options: I,
415    config: EditorTabsConfig,
416) -> El
417where
418    I: IntoIterator<Item = (V, L)>,
419    V: std::fmt::Display,
420    L: Into<String>,
421{
422    let caller = Location::caller();
423    let key = key.into();
424    let current_str = current.to_string();
425
426    let mut children: Vec<El> = options
427        .into_iter()
428        .map(|(value, label)| {
429            let selected = value.to_string() == current_str;
430            editor_tab(&key, value, None, label, selected, config).at_loc(caller)
431        })
432        .collect();
433
434    // Trailing `+` button — separated from the last tab by a small
435    // gap so it reads as a distinct "new tab" affordance rather than
436    // another tab. Ghosted (no fill, no stroke) to match the strip's
437    // flat aesthetic.
438    let add_key = editor_tab_add_key(&key);
439    let add_btn = icon_button(IconName::Plus)
440        .at_loc(caller)
441        .key(add_key)
442        .icon_size(tokens::ICON_SM)
443        .ghost()
444        .width(Size::Fixed(tokens::CONTROL_HEIGHT))
445        .height(Size::Fixed(tokens::CONTROL_HEIGHT));
446    children.push(add_btn);
447
448    El::new(Kind::Custom("editor_tabs"))
449        .at_loc(caller)
450        .axis(Axis::Row)
451        .default_gap(tokens::SPACE_1)
452        .align(Align::Center)
453        .children(children)
454        .fill(tokens::MUTED)
455        .default_padding(Sides::xy(tokens::SPACE_2, tokens::SPACE_1))
456        .width(Size::Fill(1.0))
457        .height(Size::Hug)
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::event::KeyModifiers;
464
465    fn click(key: &str) -> UiEvent {
466        UiEvent {
467            path: None,
468            kind: UiEventKind::Click,
469            key: Some(key.to_string()),
470            target: None,
471            pointer: None,
472            key_press: None,
473            text: None,
474            selection: None,
475            modifiers: KeyModifiers::default(),
476            click_count: 1,
477        }
478    }
479
480    #[test]
481    fn key_helpers_match_widget_format() {
482        assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
483        assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
484        assert_eq!(editor_tab_add_key("docs"), "docs:add");
485    }
486
487    #[test]
488    fn classify_event_recognises_all_three_actions() {
489        assert_eq!(
490            classify_event(&click("docs:tab:readme"), "docs"),
491            Some(EditorTabsAction::Select("readme")),
492        );
493        assert_eq!(
494            classify_event(&click("docs:close:readme"), "docs"),
495            Some(EditorTabsAction::Close("readme")),
496        );
497        assert_eq!(
498            classify_event(&click("docs:add"), "docs"),
499            Some(EditorTabsAction::Add),
500        );
501        // Non-matching keys fall through.
502        assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
503        assert_eq!(classify_event(&click("docs"), "docs"), None);
504    }
505
506    #[test]
507    fn classify_event_ignores_non_activating_kinds() {
508        let mut ev = click("docs:close:readme");
509        ev.kind = UiEventKind::PointerDown;
510        assert_eq!(classify_event(&ev, "docs"), None);
511        ev.kind = UiEventKind::Activate;
512        assert_eq!(
513            classify_event(&ev, "docs"),
514            Some(EditorTabsAction::Close("readme")),
515            "keyboard activation should fire close like a click",
516        );
517    }
518
519    #[test]
520    fn editor_tab_routes_via_select_key() {
521        let tab = editor_tab(
522            "docs",
523            "readme",
524            None,
525            "README.md",
526            false,
527            EditorTabsConfig::default(),
528        );
529        assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
530        assert!(tab.focusable);
531    }
532
533    #[test]
534    fn editor_tab_active_lifted_fills_with_card() {
535        let active = editor_tab(
536            "docs",
537            "readme",
538            None,
539            "README.md",
540            true,
541            EditorTabsConfig::default(),
542        );
543        let inactive = editor_tab(
544            "docs",
545            "readme",
546            None,
547            "README.md",
548            false,
549            EditorTabsConfig::default(),
550        );
551        assert_eq!(active.fill, Some(tokens::CARD));
552        assert_eq!(
553            inactive.fill, None,
554            "inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
555        );
556    }
557
558    #[test]
559    fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
560        let cfg = EditorTabsConfig {
561            active_style: ActiveTabStyle::TopAccent,
562            ..Default::default()
563        };
564        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
565        // Column with [rule, body]; the rule is the first child and
566        // carries the PRIMARY fill on the active tab.
567        assert!(active.children.len() >= 2);
568        assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
569    }
570
571    #[test]
572    fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
573        let cfg = EditorTabsConfig {
574            active_style: ActiveTabStyle::BottomRule,
575            ..Default::default()
576        };
577        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
578        let last = active.children.last().expect("at least one child");
579        assert_eq!(last.fill, Some(tokens::PRIMARY));
580    }
581
582    #[test]
583    fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
584        let cfg = EditorTabsConfig {
585            active_style: ActiveTabStyle::TopAccent,
586            ..Default::default()
587        };
588        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
589        // Rule row is still present so the tab's height stays stable
590        // across selection changes, but its fill is unset.
591        assert_eq!(inactive.children[0].fill, None);
592    }
593
594    #[test]
595    fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
596        let cfg = EditorTabsConfig {
597            close_visibility: CloseVisibility::ActiveOrHover,
598            ..Default::default()
599        };
600        // Each tab is `column([body])` (Lifted); body is the first
601        // child, which is a row of [label, close]. The close icon is
602        // always present in the layout — only its rest opacity changes
603        // — so geometry stays stable across selection.
604        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
605        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
606        let active_body = &active.children[0];
607        let inactive_body = &inactive.children[0];
608        assert_eq!(active_body.children.len(), 2);
609        assert_eq!(inactive_body.children.len(), 2);
610        // The active tab's close paints at full opacity (no modifier).
611        let active_close = &active_body.children[1];
612        assert_eq!(active_close.hover_alpha, None);
613        // The inactive tab's close is invisible at rest, fades in on
614        // hover / focus / press via the subtree interaction envelope.
615        let inactive_close = &inactive_body.children[1];
616        let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
617        assert_eq!(cfg.rest, 0.0);
618        assert_eq!(cfg.peak, 1.0);
619    }
620
621    #[test]
622    fn close_visibility_dimmed_uses_partial_rest_opacity() {
623        let cfg = EditorTabsConfig {
624            close_visibility: CloseVisibility::Dimmed,
625            ..Default::default()
626        };
627        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
628        let body = &inactive.children[0];
629        let close = &body.children[1];
630        // Dimmed sits between hidden and visible — close should rest
631        // around 0.4 alpha and ease up on hover.
632        match close.hover_alpha {
633            Some(cfg) => {
634                assert!(
635                    cfg.rest > 0.0 && cfg.rest < 1.0,
636                    "Dimmed rest should be partial; got {}",
637                    cfg.rest,
638                );
639                assert_eq!(cfg.peak, 1.0);
640            }
641            None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
642        }
643    }
644
645    #[test]
646    fn close_visibility_always_skips_hover_alpha() {
647        let cfg = EditorTabsConfig {
648            close_visibility: CloseVisibility::Always,
649            ..Default::default()
650        };
651        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
652        let body = &inactive.children[0];
653        let close = &body.children[1];
654        // `Always` is full opacity unconditionally — the modifier is a
655        // no-op at rest=1.0, so we skip attaching it to keep tree
656        // dumps for this flavor uncluttered.
657        assert_eq!(close.hover_alpha, None);
658    }
659
660    #[test]
661    fn editor_tab_leading_prepends_inside_the_body_row() {
662        let dot = crate::tree::column([crate::widgets::text::text("●")])
663            .width(Size::Fixed(8.0))
664            .height(Size::Fixed(8.0));
665        let tab = editor_tab(
666            "docs",
667            "readme",
668            Some(dot),
669            "README.md",
670            false,
671            EditorTabsConfig::default(),
672        );
673        // Outer is column([body]); body's children become
674        // [leading, label, close] when leading is Some.
675        let body = &tab.children[0];
676        assert_eq!(body.children.len(), 3);
677    }
678
679    #[test]
680    fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
681        let strip = editor_tabs(
682            "docs",
683            &"readme",
684            [("readme", "README.md"), ("main", "main.rs")],
685        );
686        // Two tabs + the trailing + button.
687        assert_eq!(strip.children.len(), 3);
688        let add = strip.children.last().unwrap();
689        assert_eq!(add.key.as_deref(), Some("docs:add"));
690    }
691
692    #[test]
693    fn editor_tabs_marks_only_the_current_value_active() {
694        let strip = editor_tabs(
695            "docs",
696            &"main",
697            [
698                ("readme", "README.md"),
699                ("main", "main.rs"),
700                ("cargo", "Cargo.toml"),
701            ],
702        );
703        assert_eq!(strip.children[0].fill, None);
704        assert_eq!(strip.children[1].fill, Some(tokens::CARD));
705        assert_eq!(strip.children[2].fill, None);
706    }
707
708    #[test]
709    fn apply_event_select_swaps_active_without_touching_tabs() {
710        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
711        let mut active = "a".to_string();
712        let next_id = || "fresh".to_string();
713        assert!(apply_event(
714            &mut tabs,
715            &mut active,
716            &click("docs:tab:b"),
717            "docs",
718            |s| Some(s.to_string()),
719            next_id,
720        ));
721        assert_eq!(active, "b");
722        assert_eq!(tabs, vec!["a", "b", "c"]);
723    }
724
725    #[test]
726    fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
727        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
728        let mut active = "b".to_string();
729        let next_id = || "fresh".to_string();
730        assert!(apply_event(
731            &mut tabs,
732            &mut active,
733            &click("docs:close:b"),
734            "docs",
735            |s| Some(s.to_string()),
736            next_id,
737        ));
738        assert_eq!(tabs, vec!["a", "c"]);
739        // The middle tab was active; closing it shifts to the same
740        // index, which is now "c".
741        assert_eq!(active, "c");
742    }
743
744    #[test]
745    fn apply_event_close_last_tab_picks_previous_neighbour() {
746        let mut tabs = vec!["a".to_string(), "b".to_string()];
747        let mut active = "b".to_string();
748        let next_id = || "fresh".to_string();
749        assert!(apply_event(
750            &mut tabs,
751            &mut active,
752            &click("docs:close:b"),
753            "docs",
754            |s| Some(s.to_string()),
755            next_id,
756        ));
757        assert_eq!(tabs, vec!["a"]);
758        assert_eq!(active, "a");
759    }
760
761    #[test]
762    fn apply_event_close_inactive_tab_leaves_active_alone() {
763        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
764        let mut active = "a".to_string();
765        let next_id = || "fresh".to_string();
766        assert!(apply_event(
767            &mut tabs,
768            &mut active,
769            &click("docs:close:c"),
770            "docs",
771            |s| Some(s.to_string()),
772            next_id,
773        ));
774        assert_eq!(tabs, vec!["a", "b"]);
775        assert_eq!(active, "a");
776    }
777
778    #[test]
779    fn apply_event_refuses_to_close_the_last_tab() {
780        let mut tabs = vec!["a".to_string()];
781        let mut active = "a".to_string();
782        let next_id = || "fresh".to_string();
783        assert!(apply_event(
784            &mut tabs,
785            &mut active,
786            &click("docs:close:a"),
787            "docs",
788            |s| Some(s.to_string()),
789            next_id,
790        ));
791        assert_eq!(
792            tabs,
793            vec!["a"],
794            "the last tab can't be closed via the helper"
795        );
796        assert_eq!(active, "a");
797    }
798
799    #[test]
800    fn apply_event_add_appends_and_activates_a_minted_tab() {
801        let mut tabs = vec!["a".to_string()];
802        let mut active = "a".to_string();
803        let mut counter = 0;
804        let next_id = || {
805            counter += 1;
806            format!("new-{counter}")
807        };
808        assert!(apply_event(
809            &mut tabs,
810            &mut active,
811            &click("docs:add"),
812            "docs",
813            |s| Some(s.to_string()),
814            next_id,
815        ));
816        assert_eq!(tabs, vec!["a", "new-1"]);
817        assert_eq!(active, "new-1");
818    }
819
820    #[test]
821    fn apply_event_returns_false_for_foreign_events() {
822        let mut tabs = vec!["a".to_string()];
823        let mut active = "a".to_string();
824        let next_id = || "fresh".to_string();
825        assert!(!apply_event(
826            &mut tabs,
827            &mut active,
828            &click("save"),
829            "docs",
830            |s| Some(s.to_string()),
831            next_id,
832        ));
833        assert_eq!(tabs, vec!["a"]);
834        assert_eq!(active, "a");
835    }
836}