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        .axis(Axis::Column)
380        .align(Align::Stretch)
381        .height(Size::Fixed(tokens::CONTROL_HEIGHT + 2.0))
382        .width(Size::Hug);
383    if matches!(config.active_style, ActiveTabStyle::Lifted) && selected {
384        tab = tab.fill(tokens::CARD).default_radius(tokens::RADIUS_SM);
385    }
386    tab
387}
388
389/// An editor-tab strip with default config (lifted active tab, dimmed
390/// close icons on inactive tabs). See [`editor_tabs_with`] for
391/// flavor overrides.
392#[track_caller]
393pub fn editor_tabs<I, V, L>(
394    key: impl Into<String>,
395    current: &impl std::fmt::Display,
396    options: I,
397) -> El
398where
399    I: IntoIterator<Item = (V, L)>,
400    V: std::fmt::Display,
401    L: Into<String>,
402{
403    editor_tabs_with(key, current, options, EditorTabsConfig::default())
404}
405
406/// An editor-tab strip with explicit configuration. Like [`editor_tabs`]
407/// but lets the caller pick the active-tab treatment and close-icon
408/// visibility.
409#[track_caller]
410pub fn editor_tabs_with<I, V, L>(
411    key: impl Into<String>,
412    current: &impl std::fmt::Display,
413    options: I,
414    config: EditorTabsConfig,
415) -> El
416where
417    I: IntoIterator<Item = (V, L)>,
418    V: std::fmt::Display,
419    L: Into<String>,
420{
421    let caller = Location::caller();
422    let key = key.into();
423    let current_str = current.to_string();
424
425    let mut children: Vec<El> = options
426        .into_iter()
427        .map(|(value, label)| {
428            let selected = value.to_string() == current_str;
429            editor_tab(&key, value, None, label, selected, config).at_loc(caller)
430        })
431        .collect();
432
433    // Trailing `+` button — separated from the last tab by a small
434    // gap so it reads as a distinct "new tab" affordance rather than
435    // another tab. Ghosted (no fill, no stroke) to match the strip's
436    // flat aesthetic.
437    let add_key = editor_tab_add_key(&key);
438    let add_btn = icon_button(IconName::Plus)
439        .at_loc(caller)
440        .key(add_key)
441        .icon_size(tokens::ICON_SM)
442        .ghost()
443        .width(Size::Fixed(tokens::CONTROL_HEIGHT))
444        .height(Size::Fixed(tokens::CONTROL_HEIGHT));
445    children.push(add_btn);
446
447    El::new(Kind::Custom("editor_tabs"))
448        .at_loc(caller)
449        .axis(Axis::Row)
450        .default_gap(tokens::SPACE_1)
451        .align(Align::Center)
452        .children(children)
453        .fill(tokens::MUTED)
454        .default_padding(Sides::xy(tokens::SPACE_2, tokens::SPACE_1))
455        .width(Size::Fill(1.0))
456        .height(Size::Hug)
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::event::KeyModifiers;
463
464    fn click(key: &str) -> UiEvent {
465        UiEvent {
466            path: None,
467            kind: UiEventKind::Click,
468            key: Some(key.to_string()),
469            target: None,
470            pointer: None,
471            key_press: None,
472            text: None,
473            selection: None,
474            modifiers: KeyModifiers::default(),
475            click_count: 1,
476        }
477    }
478
479    #[test]
480    fn key_helpers_match_widget_format() {
481        assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
482        assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
483        assert_eq!(editor_tab_add_key("docs"), "docs:add");
484    }
485
486    #[test]
487    fn classify_event_recognises_all_three_actions() {
488        assert_eq!(
489            classify_event(&click("docs:tab:readme"), "docs"),
490            Some(EditorTabsAction::Select("readme")),
491        );
492        assert_eq!(
493            classify_event(&click("docs:close:readme"), "docs"),
494            Some(EditorTabsAction::Close("readme")),
495        );
496        assert_eq!(
497            classify_event(&click("docs:add"), "docs"),
498            Some(EditorTabsAction::Add),
499        );
500        // Non-matching keys fall through.
501        assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
502        assert_eq!(classify_event(&click("docs"), "docs"), None);
503    }
504
505    #[test]
506    fn classify_event_ignores_non_activating_kinds() {
507        let mut ev = click("docs:close:readme");
508        ev.kind = UiEventKind::PointerDown;
509        assert_eq!(classify_event(&ev, "docs"), None);
510        ev.kind = UiEventKind::Activate;
511        assert_eq!(
512            classify_event(&ev, "docs"),
513            Some(EditorTabsAction::Close("readme")),
514            "keyboard activation should fire close like a click",
515        );
516    }
517
518    #[test]
519    fn editor_tab_routes_via_select_key() {
520        let tab = editor_tab(
521            "docs",
522            "readme",
523            None,
524            "README.md",
525            false,
526            EditorTabsConfig::default(),
527        );
528        assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
529        assert!(tab.focusable);
530    }
531
532    #[test]
533    fn editor_tab_active_lifted_fills_with_card() {
534        let active = editor_tab(
535            "docs",
536            "readme",
537            None,
538            "README.md",
539            true,
540            EditorTabsConfig::default(),
541        );
542        let inactive = editor_tab(
543            "docs",
544            "readme",
545            None,
546            "README.md",
547            false,
548            EditorTabsConfig::default(),
549        );
550        assert_eq!(active.fill, Some(tokens::CARD));
551        assert_eq!(
552            inactive.fill, None,
553            "inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
554        );
555    }
556
557    #[test]
558    fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
559        let cfg = EditorTabsConfig {
560            active_style: ActiveTabStyle::TopAccent,
561            ..Default::default()
562        };
563        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
564        // Column with [rule, body]; the rule is the first child and
565        // carries the PRIMARY fill on the active tab.
566        assert!(active.children.len() >= 2);
567        assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
568    }
569
570    #[test]
571    fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
572        let cfg = EditorTabsConfig {
573            active_style: ActiveTabStyle::BottomRule,
574            ..Default::default()
575        };
576        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
577        let last = active.children.last().expect("at least one child");
578        assert_eq!(last.fill, Some(tokens::PRIMARY));
579    }
580
581    #[test]
582    fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
583        let cfg = EditorTabsConfig {
584            active_style: ActiveTabStyle::TopAccent,
585            ..Default::default()
586        };
587        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
588        // Rule row is still present so the tab's height stays stable
589        // across selection changes, but its fill is unset.
590        assert_eq!(inactive.children[0].fill, None);
591    }
592
593    #[test]
594    fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
595        let cfg = EditorTabsConfig {
596            close_visibility: CloseVisibility::ActiveOrHover,
597            ..Default::default()
598        };
599        // Each tab is `column([body])` (Lifted); body is the first
600        // child, which is a row of [label, close]. The close icon is
601        // always present in the layout — only its rest opacity changes
602        // — so geometry stays stable across selection.
603        let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
604        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
605        let active_body = &active.children[0];
606        let inactive_body = &inactive.children[0];
607        assert_eq!(active_body.children.len(), 2);
608        assert_eq!(inactive_body.children.len(), 2);
609        // The active tab's close paints at full opacity (no modifier).
610        let active_close = &active_body.children[1];
611        assert_eq!(active_close.hover_alpha, None);
612        // The inactive tab's close is invisible at rest, fades in on
613        // hover / focus / press via the subtree interaction envelope.
614        let inactive_close = &inactive_body.children[1];
615        let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
616        assert_eq!(cfg.rest, 0.0);
617        assert_eq!(cfg.peak, 1.0);
618    }
619
620    #[test]
621    fn close_visibility_dimmed_uses_partial_rest_opacity() {
622        let cfg = EditorTabsConfig {
623            close_visibility: CloseVisibility::Dimmed,
624            ..Default::default()
625        };
626        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
627        let body = &inactive.children[0];
628        let close = &body.children[1];
629        // Dimmed sits between hidden and visible — close should rest
630        // around 0.4 alpha and ease up on hover.
631        match close.hover_alpha {
632            Some(cfg) => {
633                assert!(
634                    cfg.rest > 0.0 && cfg.rest < 1.0,
635                    "Dimmed rest should be partial; got {}",
636                    cfg.rest,
637                );
638                assert_eq!(cfg.peak, 1.0);
639            }
640            None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
641        }
642    }
643
644    #[test]
645    fn close_visibility_always_skips_hover_alpha() {
646        let cfg = EditorTabsConfig {
647            close_visibility: CloseVisibility::Always,
648            ..Default::default()
649        };
650        let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
651        let body = &inactive.children[0];
652        let close = &body.children[1];
653        // `Always` is full opacity unconditionally — the modifier is a
654        // no-op at rest=1.0, so we skip attaching it to keep tree
655        // dumps for this flavor uncluttered.
656        assert_eq!(close.hover_alpha, None);
657    }
658
659    #[test]
660    fn editor_tab_leading_prepends_inside_the_body_row() {
661        let dot = crate::tree::column([crate::widgets::text::text("●")])
662            .width(Size::Fixed(8.0))
663            .height(Size::Fixed(8.0));
664        let tab = editor_tab(
665            "docs",
666            "readme",
667            Some(dot),
668            "README.md",
669            false,
670            EditorTabsConfig::default(),
671        );
672        // Outer is column([body]); body's children become
673        // [leading, label, close] when leading is Some.
674        let body = &tab.children[0];
675        assert_eq!(body.children.len(), 3);
676    }
677
678    #[test]
679    fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
680        let strip = editor_tabs(
681            "docs",
682            &"readme",
683            [("readme", "README.md"), ("main", "main.rs")],
684        );
685        // Two tabs + the trailing + button.
686        assert_eq!(strip.children.len(), 3);
687        let add = strip.children.last().unwrap();
688        assert_eq!(add.key.as_deref(), Some("docs:add"));
689    }
690
691    #[test]
692    fn editor_tabs_marks_only_the_current_value_active() {
693        let strip = editor_tabs(
694            "docs",
695            &"main",
696            [
697                ("readme", "README.md"),
698                ("main", "main.rs"),
699                ("cargo", "Cargo.toml"),
700            ],
701        );
702        assert_eq!(strip.children[0].fill, None);
703        assert_eq!(strip.children[1].fill, Some(tokens::CARD));
704        assert_eq!(strip.children[2].fill, None);
705    }
706
707    #[test]
708    fn apply_event_select_swaps_active_without_touching_tabs() {
709        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
710        let mut active = "a".to_string();
711        let next_id = || "fresh".to_string();
712        assert!(apply_event(
713            &mut tabs,
714            &mut active,
715            &click("docs:tab:b"),
716            "docs",
717            |s| Some(s.to_string()),
718            next_id,
719        ));
720        assert_eq!(active, "b");
721        assert_eq!(tabs, vec!["a", "b", "c"]);
722    }
723
724    #[test]
725    fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
726        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
727        let mut active = "b".to_string();
728        let next_id = || "fresh".to_string();
729        assert!(apply_event(
730            &mut tabs,
731            &mut active,
732            &click("docs:close:b"),
733            "docs",
734            |s| Some(s.to_string()),
735            next_id,
736        ));
737        assert_eq!(tabs, vec!["a", "c"]);
738        // The middle tab was active; closing it shifts to the same
739        // index, which is now "c".
740        assert_eq!(active, "c");
741    }
742
743    #[test]
744    fn apply_event_close_last_tab_picks_previous_neighbour() {
745        let mut tabs = vec!["a".to_string(), "b".to_string()];
746        let mut active = "b".to_string();
747        let next_id = || "fresh".to_string();
748        assert!(apply_event(
749            &mut tabs,
750            &mut active,
751            &click("docs:close:b"),
752            "docs",
753            |s| Some(s.to_string()),
754            next_id,
755        ));
756        assert_eq!(tabs, vec!["a"]);
757        assert_eq!(active, "a");
758    }
759
760    #[test]
761    fn apply_event_close_inactive_tab_leaves_active_alone() {
762        let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
763        let mut active = "a".to_string();
764        let next_id = || "fresh".to_string();
765        assert!(apply_event(
766            &mut tabs,
767            &mut active,
768            &click("docs:close:c"),
769            "docs",
770            |s| Some(s.to_string()),
771            next_id,
772        ));
773        assert_eq!(tabs, vec!["a", "b"]);
774        assert_eq!(active, "a");
775    }
776
777    #[test]
778    fn apply_event_refuses_to_close_the_last_tab() {
779        let mut tabs = vec!["a".to_string()];
780        let mut active = "a".to_string();
781        let next_id = || "fresh".to_string();
782        assert!(apply_event(
783            &mut tabs,
784            &mut active,
785            &click("docs:close:a"),
786            "docs",
787            |s| Some(s.to_string()),
788            next_id,
789        ));
790        assert_eq!(
791            tabs,
792            vec!["a"],
793            "the last tab can't be closed via the helper"
794        );
795        assert_eq!(active, "a");
796    }
797
798    #[test]
799    fn apply_event_add_appends_and_activates_a_minted_tab() {
800        let mut tabs = vec!["a".to_string()];
801        let mut active = "a".to_string();
802        let mut counter = 0;
803        let next_id = || {
804            counter += 1;
805            format!("new-{counter}")
806        };
807        assert!(apply_event(
808            &mut tabs,
809            &mut active,
810            &click("docs:add"),
811            "docs",
812            |s| Some(s.to_string()),
813            next_id,
814        ));
815        assert_eq!(tabs, vec!["a", "new-1"]);
816        assert_eq!(active, "new-1");
817    }
818
819    #[test]
820    fn apply_event_returns_false_for_foreign_events() {
821        let mut tabs = vec!["a".to_string()];
822        let mut active = "a".to_string();
823        let next_id = || "fresh".to_string();
824        assert!(!apply_event(
825            &mut tabs,
826            &mut active,
827            &click("save"),
828            "docs",
829            |s| Some(s.to_string()),
830            next_id,
831        ));
832        assert_eq!(tabs, vec!["a"]);
833        assert_eq!(active, "a");
834    }
835}