Skip to main content

tui_lipan/widgets/tabs/
mod.rs

1//! Tabs widget.
2
3mod layout;
4mod node;
5mod reconcile;
6
7pub use layout::measure_tabs;
8pub use node::TabsNode;
9pub use reconcile::reconcile_tabs;
10
11use std::sync::Arc;
12
13use crate::callback::{Callback, KeyHandler};
14use crate::core::element::{Element, ElementKind};
15use crate::core::event::MouseEvent;
16use crate::style::{BorderStyle, Length, Padding, Style, StyleSlot};
17use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
18
19/// Tab overflow policy.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21pub enum TabsOverflow {
22    /// Current behavior: greedily pack tabs from the start.
23    #[default]
24    Clip,
25    /// Keep all tabs visible by allocating per-tab budgets and ellipsizing labels.
26    Ellipsis,
27}
28
29/// A tab change event.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub struct TabsEvent {
32    /// Active tab index.
33    pub index: usize,
34}
35
36/// A tab title.
37#[derive(Clone, Debug)]
38pub struct Tab {
39    pub(crate) label: Arc<str>,
40    pub(crate) style: Style,
41    pub(crate) capped: bool,
42}
43
44impl Tab {
45    /// Create a new tab.
46    pub fn new(label: impl Into<Arc<str>>) -> Self {
47        Self {
48            label: label.into(),
49            style: Style::default(),
50            capped: false,
51        }
52    }
53
54    /// Set style.
55    pub fn style(mut self, style: Style) -> Self {
56        self.style = style;
57        self
58    }
59
60    /// Draw this tab's end caps even when it is neither active nor hovered.
61    ///
62    /// [`Tabs::caps`] normally shapes only the active and hovered tabs, because those are the two
63    /// the widget knows are emphasized. A tab carrying its own background for an app-specific reason
64    /// — an unsaved marker, an error state, a workspace waiting on input — is emphasized too, and
65    /// without this reads as a flat colored block beside shaped peers.
66    ///
67    /// The remaining cap conditions still apply: the tab must be untruncated, its background must
68    /// differ from the strip's (there has to be a color to fill the glyph with), and the caps must
69    /// fit the padding cells they replace.
70    pub fn capped(mut self, capped: bool) -> Self {
71        self.capped = capped;
72        self
73    }
74}
75
76impl From<&'static str> for Tab {
77    fn from(value: &'static str) -> Self {
78        Self::new(value)
79    }
80}
81
82impl From<String> for Tab {
83    fn from(value: String) -> Self {
84        Self::new(value)
85    }
86}
87
88impl From<Arc<str>> for Tab {
89    fn from(value: Arc<str>) -> Self {
90        Self::new(value)
91    }
92}
93
94/// A horizontal tab bar.
95#[derive(Clone)]
96pub struct Tabs {
97    pub(crate) tabs: Arc<[Tab]>,
98    pub(crate) active: usize,
99    pub(crate) style: Style,
100    pub(crate) focus_style: StyleSlot,
101    pub(crate) hover_style: StyleSlot,
102    pub(crate) tab_hover_style: StyleSlot,
103    pub(crate) active_style: StyleSlot,
104    pub(crate) divider: char,
105    pub(crate) caps: Option<(char, char)>,
106    pub(crate) overflow: TabsOverflow,
107    pub(crate) border: bool,
108    pub(crate) border_style: BorderStyle,
109    pub(crate) padding: Padding,
110    pub(crate) width: Length,
111    pub(crate) height: Length,
112    pub(crate) on_change: Option<Callback<TabsEvent>>,
113    pub(crate) on_click: Option<Callback<MouseEvent>>,
114    pub(crate) on_key: Option<KeyHandler>,
115    pub(crate) disabled: bool,
116    pub(crate) disabled_style: Style,
117    pub(crate) focusable: bool,
118    pub(crate) tab_stop: bool,
119    pub(crate) on_focus: Option<Callback<()>>,
120    pub(crate) on_blur: Option<Callback<()>>,
121}
122
123impl Default for Tabs {
124    fn default() -> Self {
125        Self {
126            tabs: Arc::new([]),
127            active: 0,
128            style: Style::default(),
129            focus_style: StyleSlot::Inherit,
130            hover_style: StyleSlot::Inherit,
131            tab_hover_style: StyleSlot::Inherit,
132            active_style: StyleSlot::Inherit,
133            divider: '│',
134            caps: None,
135            overflow: TabsOverflow::Clip,
136            border: false,
137            border_style: BorderStyle::Plain,
138            padding: Padding::default(),
139            width: Length::Flex(1),
140            height: Length::Auto,
141            on_change: None,
142            on_click: None,
143            on_key: None,
144            disabled: false,
145            disabled_style: Style::default(),
146            focusable: false,
147            tab_stop: true,
148            on_focus: None,
149            on_blur: None,
150        }
151    }
152}
153
154impl Tabs {
155    /// Create an empty tab bar.
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// Replace tabs.
161    pub fn tabs<I>(mut self, tabs: I) -> Self
162    where
163        I: IntoIterator<Item = Tab>,
164    {
165        self.tabs = tabs.into_iter().collect::<Vec<_>>().into();
166        self
167    }
168
169    /// Set tabs from a shared slice.
170    pub fn tabs_arc(mut self, tabs: Arc<[Tab]>) -> Self {
171        self.tabs = tabs;
172        self
173    }
174
175    /// Add a tab.
176    pub fn tab(mut self, tab: impl Into<Tab>) -> Self {
177        let mut tabs = self.tabs.to_vec();
178        tabs.push(tab.into());
179        self.tabs = tabs.into();
180        self
181    }
182
183    /// Set active tab index.
184    pub fn active(mut self, active: usize) -> Self {
185        self.active = active;
186        self
187    }
188
189    /// Set base style.
190    pub fn style(mut self, style: Style) -> Self {
191        self.style = style;
192        self
193    }
194
195    /// Set style when the tabs widget is focused.
196    pub fn focus_style(mut self, style: Style) -> Self {
197        self.focus_style = StyleSlot::Replace(style);
198        self
199    }
200
201    /// Extend the active theme's focus style with additional fields.
202    pub fn extend_focus_style(mut self, style: Style) -> Self {
203        self.focus_style = StyleSlot::Extend(style);
204        self
205    }
206
207    /// Inherit focus style from the active theme.
208    pub fn inherit_focus_style(mut self) -> Self {
209        self.focus_style = StyleSlot::Inherit;
210        self
211    }
212
213    /// Set style when tabs widget is hovered.
214    pub fn hover_style(mut self, style: Style) -> Self {
215        self.hover_style = StyleSlot::Replace(style);
216        self
217    }
218
219    /// Extend the active theme's hover style with additional fields.
220    pub fn extend_hover_style(mut self, style: Style) -> Self {
221        self.hover_style = StyleSlot::Extend(style);
222        self
223    }
224
225    /// Inherit hover style from the active theme.
226    pub fn inherit_hover_style(mut self) -> Self {
227        self.hover_style = StyleSlot::Inherit;
228        self
229    }
230
231    /// Set style for hovered tab.
232    pub fn tab_hover_style(mut self, style: Style) -> Self {
233        self.tab_hover_style = StyleSlot::Replace(style);
234        self
235    }
236
237    /// Extend the active theme's tab hover style with additional fields.
238    pub fn extend_tab_hover_style(mut self, style: Style) -> Self {
239        self.tab_hover_style = StyleSlot::Extend(style);
240        self
241    }
242
243    /// Inherit tab hover style from the active theme.
244    pub fn inherit_tab_hover_style(mut self) -> Self {
245        self.tab_hover_style = StyleSlot::Inherit;
246        self
247    }
248
249    /// Set active tab style.
250    pub fn active_style(mut self, style: Style) -> Self {
251        self.active_style = StyleSlot::Replace(style);
252        self
253    }
254
255    /// Extend the active theme's active-tab style with additional fields.
256    pub fn extend_active_style(mut self, style: Style) -> Self {
257        self.active_style = StyleSlot::Extend(style);
258        self
259    }
260
261    /// Inherit active-tab style from the active theme.
262    pub fn inherit_active_style(mut self) -> Self {
263        self.active_style = StyleSlot::Inherit;
264        self
265    }
266
267    /// Set divider character.
268    pub fn divider(mut self, ch: char) -> Self {
269        self.divider = ch;
270        self
271    }
272
273    /// Set the `(left, right)` end-cap glyphs drawn around the active and hovered tabs.
274    ///
275    /// Each cap replaces one of the tab's two padding cells, so the tab keeps its
276    /// measured width and hit region. The glyphs are painted in the tab's own
277    /// background color over the strip background, so the tab reads as a rounded or
278    /// pointed pill (pass powerline separators for that look). `None` (the default)
279    /// keeps flat space padding on every tab.
280    ///
281    /// A tab falls back to flat padding when it is truncated by the overflow policy,
282    /// when its background matches the strip's (leaving nothing to fill the glyph
283    /// with), or when either cap is not exactly one cell wide. Caps must be
284    /// single-width because a wider glyph would push later tabs off the columns the
285    /// widget hit-tests against.
286    pub fn caps(mut self, caps: Option<(char, char)>) -> Self {
287        self.caps = caps;
288        self
289    }
290
291    /// Set overflow policy.
292    pub fn overflow(mut self, overflow: TabsOverflow) -> Self {
293        self.overflow = overflow;
294        self
295    }
296
297    /// Draw a border.
298    pub fn border(mut self, border: bool) -> Self {
299        self.border = border;
300        self
301    }
302
303    /// Set border style.
304    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
305        self.border_style = border_style;
306        self
307    }
308
309    /// Set padding.
310    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
311        self.padding = padding.into();
312        self
313    }
314
315    /// Override requested width.
316    pub fn width(mut self, width: Length) -> Self {
317        self.width = width;
318        self
319    }
320
321    /// Override requested height.
322    pub fn height(mut self, height: Length) -> Self {
323        self.height = height;
324        self
325    }
326
327    /// Callback fired when the active tab changes.
328    pub fn on_change(mut self, cb: Callback<TabsEvent>) -> Self {
329        self.on_change = Some(cb);
330        self
331    }
332
333    /// Set on-click handler.
334    pub fn on_click(mut self, cb: Callback<MouseEvent>) -> Self {
335        self.on_click = Some(cb);
336        self
337    }
338
339    /// Set on-key handler.
340    pub fn on_key(mut self, handler: KeyHandler) -> Self {
341        self.on_key = Some(handler);
342        self
343    }
344
345    /// Set disabled state.
346    pub fn disabled(mut self, disabled: bool) -> Self {
347        self.disabled = disabled;
348        self
349    }
350
351    /// Set disabled style.
352    pub fn disabled_style(mut self, style: Style) -> Self {
353        self.disabled_style = style;
354        self
355    }
356
357    /// Control whether the node is focusable.
358    pub fn focusable(mut self, focusable: bool) -> Self {
359        self.focusable = focusable;
360        self
361    }
362
363    /// Control whether the tabs participate in sequential focus navigation.
364    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
365        self.tab_stop = tab_stop;
366        self
367    }
368
369    /// Set the callback fired when the tabs receive focus.
370    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
371        self.on_focus = Some(cb);
372        self
373    }
374
375    /// Set the callback fired when the tabs lose focus.
376    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
377        self.on_blur = Some(cb);
378        self
379    }
380
381    pub(crate) fn index_at_col(
382        tabs: &[Tab],
383        divider: char,
384        overflow: TabsOverflow,
385        inner_w: usize,
386        col: usize,
387    ) -> Option<usize> {
388        let budgets = tab_width_budgets(tabs, divider, inner_w, overflow);
389        let mut used = 0usize;
390
391        for (i, tab) in tabs.iter().enumerate() {
392            if used >= inner_w {
393                break;
394            }
395
396            let remaining = budgets.as_ref().map_or_else(
397                || inner_w.saturating_sub(used).min(u16::MAX as usize),
398                |budgets| budgets.get(i).copied().unwrap_or(0) as usize,
399            );
400            let tab_width = tab_segment_width(tab.label.as_ref(), remaining);
401            if col < used.saturating_add(tab_width) {
402                return Some(i);
403            }
404            used = used.saturating_add(tab_width);
405
406            if used >= inner_w {
407                break;
408            }
409
410            if i + 1 < tabs.len() {
411                let remaining = inner_w.saturating_sub(used);
412                let divider_width = tab_divider_width(divider, remaining);
413                if col < used.saturating_add(divider_width) {
414                    return None;
415                }
416                used = used.saturating_add(divider_width);
417            }
418        }
419
420        None
421    }
422}
423
424impl From<Tabs> for Element {
425    fn from(value: Tabs) -> Self {
426        Element::new(ElementKind::Tabs(value))
427    }
428}
429
430impl crate::layout::hash::LayoutHash for Tabs {
431    fn layout_hash(
432        &self,
433        hasher: &mut impl std::hash::Hasher,
434        _recurse: &dyn Fn(&Element) -> Option<u64>,
435    ) -> Option<()> {
436        use std::hash::Hash;
437        self.width.hash(hasher);
438        self.height.hash(hasher);
439        self.border.hash(hasher);
440        self.border_style.hash(hasher);
441        self.padding.hash(hasher);
442        self.tabs.len().hash(hasher);
443        self.active.hash(hasher);
444        self.divider.hash(hasher);
445        self.overflow.hash(hasher);
446        Some(())
447    }
448}
449
450/// Return rendered width budget per tab for the given available width.
451///
452/// For `TabsOverflow::Clip`, returns `None` so callers keep the greedy path.
453pub(crate) fn tab_width_budgets(
454    tabs: &[Tab],
455    divider: char,
456    max_w: usize,
457    overflow: TabsOverflow,
458) -> Option<Vec<u16>> {
459    if overflow == TabsOverflow::Clip {
460        return None;
461    }
462
463    if tabs.is_empty() {
464        return Some(Vec::new());
465    }
466
467    let n = tabs.len();
468    let div_w = UnicodeWidthChar::width(divider).unwrap_or(1);
469    let divider_budget = div_w.saturating_mul(n.saturating_sub(1));
470    let usable = max_w.saturating_sub(divider_budget);
471
472    let nat: Vec<usize> = tabs
473        .iter()
474        .map(|tab| UnicodeWidthStr::width(tab.label.as_ref()).saturating_add(2))
475        .collect();
476    let nat_sum = nat.iter().copied().sum::<usize>();
477    if nat_sum <= usable {
478        return Some(
479            nat.into_iter()
480                .map(|w| w.min(u16::MAX as usize) as u16)
481                .collect(),
482        );
483    }
484
485    const MIN_TAB_CELLS: usize = 3;
486    let min_total = MIN_TAB_CELLS.saturating_mul(n);
487    if usable < min_total {
488        let each = (usable / n).max(1).min(u16::MAX as usize) as u16;
489        return Some(vec![each; n]);
490    }
491
492    let mut alloc = vec![MIN_TAB_CELLS; n];
493    let mut caps: Vec<usize> = nat
494        .iter()
495        .map(|&w| w.saturating_sub(MIN_TAB_CELLS))
496        .collect();
497    let mut extra = usable.saturating_sub(min_total);
498
499    let pass1 = allocate_proportional(&caps, &nat, extra);
500    for i in 0..n {
501        alloc[i] = alloc[i].saturating_add(pass1[i]);
502        caps[i] = caps[i].saturating_sub(pass1[i]);
503    }
504    extra = extra.saturating_sub(pass1.iter().copied().sum::<usize>());
505
506    if extra > 0 {
507        let pass2 = allocate_proportional(&caps, &nat, extra);
508        for i in 0..n {
509            alloc[i] = alloc[i].saturating_add(pass2[i]);
510        }
511    }
512
513    Some(
514        alloc
515            .into_iter()
516            .map(|w| w.min(u16::MAX as usize) as u16)
517            .collect(),
518    )
519}
520
521fn allocate_proportional(caps: &[usize], weights: &[usize], budget: usize) -> Vec<usize> {
522    let n = caps.len();
523    if budget == 0 || n == 0 {
524        return vec![0; n];
525    }
526
527    let active_weight_sum = caps
528        .iter()
529        .zip(weights.iter())
530        .filter(|(cap, _)| **cap > 0)
531        .map(|(_, w)| *w)
532        .sum::<usize>();
533    if active_weight_sum == 0 {
534        return vec![0; n];
535    }
536
537    let mut out = vec![0usize; n];
538    let mut fracs = Vec::with_capacity(n);
539
540    for i in 0..n {
541        if caps[i] == 0 {
542            fracs.push((i, -1.0_f64));
543            continue;
544        }
545
546        let exact = (budget as f64) * (weights[i] as f64) / (active_weight_sum as f64);
547        let grant = (exact.floor() as usize).min(caps[i]);
548        out[i] = grant;
549        fracs.push((i, exact - (grant as f64)));
550    }
551
552    let mut leftover = budget
553        .saturating_sub(out.iter().copied().sum::<usize>())
554        .min(caps.iter().copied().sum::<usize>());
555
556    fracs.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
557
558    while leftover > 0 {
559        let mut progressed = false;
560        for (idx, _) in &fracs {
561            if leftover == 0 {
562                break;
563            }
564            if out[*idx] < caps[*idx] {
565                out[*idx] += 1;
566                leftover -= 1;
567                progressed = true;
568            }
569        }
570        if !progressed {
571            break;
572        }
573    }
574
575    out
576}
577
578/// Width of the segment rendered from one tab label, without allocating its text.
579pub(crate) fn tab_segment_width(label: &str, max_w: usize) -> usize {
580    let full_w = UnicodeWidthStr::width(label).saturating_add(2);
581    if full_w <= max_w {
582        return full_w;
583    }
584    if max_w == 0 {
585        return 0;
586    }
587
588    let ellipsis_w = UnicodeWidthChar::width('…').unwrap_or(1).max(1);
589    if max_w <= ellipsis_w {
590        return ellipsis_w;
591    }
592
593    let target = max_w.saturating_sub(ellipsis_w);
594    let mut consumed: usize = 1; // The leading padding cell always fits because target is non-zero.
595    let mut label_end = 0;
596    let mut label_complete = true;
597    for (offset, ch) in label.char_indices() {
598        let char_w = crate::utils::text::char_visual_width(ch, None);
599        if consumed.saturating_add(char_w) > target {
600            label_complete = false;
601            break;
602        }
603        consumed = consumed.saturating_add(char_w);
604        label_end = offset.saturating_add(ch.len_utf8());
605    }
606
607    let trailing_padding = label_complete && consumed < target;
608    1usize
609        .saturating_add(UnicodeWidthStr::width(&label[..label_end]))
610        .saturating_add(usize::from(trailing_padding))
611        .saturating_add(ellipsis_w)
612}
613
614pub(crate) fn tab_divider_width(divider: char, max_w: usize) -> usize {
615    let divider_w = UnicodeWidthChar::width(divider).unwrap_or(1);
616    if divider_w <= max_w {
617        return divider_w;
618    }
619    if max_w == 0 {
620        return 0;
621    }
622    UnicodeWidthChar::width('…').unwrap_or(1).max(1)
623}
624
625#[cfg(test)]
626mod tests {
627    use super::{Tab, Tabs, TabsOverflow, tab_divider_width, tab_segment_width, tab_width_budgets};
628    use unicode_width::UnicodeWidthStr;
629
630    fn mk_tabs(labels: &[&str]) -> Vec<Tab> {
631        labels.iter().map(|l| Tab::new(*l)).collect()
632    }
633
634    #[test]
635    fn index_at_col_rejects_divider_cells_for_clip_and_ellipsis() {
636        let tabs = mk_tabs(&["a", "b"]);
637
638        // Each tab is three cells wide; the wide divider occupies columns 3 and 4.
639        assert_eq!(
640            Tabs::index_at_col(&tabs, '好', TabsOverflow::Clip, 8, 2),
641            Some(0)
642        );
643        assert_eq!(
644            Tabs::index_at_col(&tabs, '好', TabsOverflow::Clip, 8, 3),
645            None
646        );
647        assert_eq!(
648            Tabs::index_at_col(&tabs, '好', TabsOverflow::Clip, 8, 4),
649            None
650        );
651        assert_eq!(
652            Tabs::index_at_col(&tabs, '好', TabsOverflow::Clip, 8, 5),
653            Some(1)
654        );
655
656        // With ellipsis budgets below the minimum tab width, the clipped divider remains inert.
657        let tabs = mk_tabs(&["a", "b", "c"]);
658        assert_eq!(
659            Tabs::index_at_col(&tabs, '好', TabsOverflow::Ellipsis, 5, 0),
660            Some(0)
661        );
662        assert_eq!(
663            Tabs::index_at_col(&tabs, '好', TabsOverflow::Ellipsis, 5, 1),
664            None
665        );
666        assert_eq!(
667            Tabs::index_at_col(&tabs, '好', TabsOverflow::Ellipsis, 5, 2),
668            None
669        );
670        assert_eq!(
671            Tabs::index_at_col(&tabs, '好', TabsOverflow::Ellipsis, 5, 3),
672            Some(1)
673        );
674        assert_eq!(
675            Tabs::index_at_col(&tabs, '好', TabsOverflow::Ellipsis, 5, 4),
676            None
677        );
678    }
679
680    #[test]
681    fn index_at_col_uses_actual_width_of_wide_segments() {
682        let tabs = mk_tabs(&["你", "b"]);
683
684        // Clip truncates the wide label to " …", leaving the divider at column 2.
685        assert_eq!(
686            Tabs::index_at_col(&tabs, '|', TabsOverflow::Clip, 3, 1),
687            Some(0)
688        );
689        assert_eq!(
690            Tabs::index_at_col(&tabs, '|', TabsOverflow::Clip, 3, 2),
691            None
692        );
693
694        let tabs = mk_tabs(&["你好", "你好"]);
695
696        // The first budget is five cells, but " 你好 " truncates to " 你…" at four cells.
697        // The rendered divider is therefore at column 4, not at the nominal budget boundary 5.
698        assert_eq!(
699            Tabs::index_at_col(&tabs, '|', TabsOverflow::Ellipsis, 10, 4),
700            None
701        );
702        assert_eq!(
703            Tabs::index_at_col(&tabs, '|', TabsOverflow::Ellipsis, 10, 5),
704            Some(1)
705        );
706    }
707
708    #[test]
709    fn allocation_free_segment_widths_match_materialized_truncation() {
710        for label in ["a", "你", "你好", "e\u{301}", "👩‍💻"] {
711            for width in 0..=8 {
712                let full = format!(" {label} ");
713                let rendered = crate::utils::text::truncate_end_with_ellipsis(&full, width);
714                assert_eq!(
715                    tab_segment_width(label, width),
716                    UnicodeWidthStr::width(rendered.as_ref()),
717                    "segment width mismatch for {label:?} at width {width}"
718                );
719            }
720        }
721
722        for divider in ['|', '好', '\u{301}'] {
723            let text = divider.to_string();
724            for width in 0..=3 {
725                let rendered = crate::utils::text::truncate_end_with_ellipsis(&text, width);
726                assert_eq!(
727                    tab_divider_width(divider, width),
728                    UnicodeWidthStr::width(rendered.as_ref()),
729                    "divider width mismatch for {divider:?} at width {width}"
730                );
731            }
732        }
733    }
734
735    #[test]
736    fn budgets_equal_labels_exact_fit_under_and_over() {
737        let tabs = mk_tabs(&["aa", "bb", "cc"]);
738
739        assert_eq!(
740            tab_width_budgets(&tabs, '|', 14, TabsOverflow::Ellipsis),
741            Some(vec![4, 4, 4])
742        );
743        assert_eq!(
744            tab_width_budgets(&tabs, '|', 11, TabsOverflow::Ellipsis),
745            Some(vec![3, 3, 3])
746        );
747        assert_eq!(
748            tab_width_budgets(&tabs, '|', 20, TabsOverflow::Ellipsis),
749            Some(vec![4, 4, 4])
750        );
751    }
752
753    #[test]
754    fn budgets_long_label_eats_slack_first() {
755        let tabs = mk_tabs(&["x", "super-long-label", "y"]);
756        let budgets = tab_width_budgets(&tabs, '|', 20, TabsOverflow::Ellipsis).unwrap();
757
758        assert_eq!(budgets.len(), 3);
759        assert_eq!(budgets[0], 3);
760        assert_eq!(budgets[2], 3);
761        assert!(budgets[1] > budgets[0]);
762    }
763
764    #[test]
765    fn budgets_single_tab() {
766        let tabs = mk_tabs(&["hello"]);
767        assert_eq!(
768            tab_width_budgets(&tabs, '|', 7, TabsOverflow::Ellipsis),
769            Some(vec![7])
770        );
771        assert_eq!(
772            tab_width_budgets(&tabs, '|', 3, TabsOverflow::Ellipsis),
773            Some(vec![3])
774        );
775    }
776
777    #[test]
778    fn budgets_zero_tabs_and_zero_width() {
779        let tabs = mk_tabs(&[]);
780        assert_eq!(
781            tab_width_budgets(&tabs, '|', 0, TabsOverflow::Ellipsis),
782            Some(vec![])
783        );
784
785        let one = mk_tabs(&["a", "b", "c"]);
786        assert_eq!(
787            tab_width_budgets(&one, '|', 0, TabsOverflow::Ellipsis),
788            Some(vec![1, 1, 1])
789        );
790    }
791
792    #[test]
793    fn budgets_with_wide_divider() {
794        let tabs = mk_tabs(&["aa", "bb"]);
795        // Divider '好' has width 2, so max_w=8 leaves usable=6.
796        assert_eq!(
797            tab_width_budgets(&tabs, '好', 8, TabsOverflow::Ellipsis),
798            Some(vec![3, 3])
799        );
800    }
801
802    #[test]
803    fn tabs_arc_preserves_shared_slice() {
804        use super::Tabs;
805        use std::sync::Arc;
806
807        let tabs: Arc<[Tab]> = Arc::from([Tab::new("one"), Tab::new("two")]);
808        let bar = Tabs::new().tabs_arc(Arc::clone(&tabs));
809        assert!(Arc::ptr_eq(&bar.tabs, &tabs));
810    }
811}