1mod 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21pub enum TabsOverflow {
22 #[default]
24 Clip,
25 Ellipsis,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub struct TabsEvent {
32 pub index: usize,
34}
35
36#[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 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 pub fn style(mut self, style: Style) -> Self {
56 self.style = style;
57 self
58 }
59
60 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#[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 pub fn new() -> Self {
157 Self::default()
158 }
159
160 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 pub fn tabs_arc(mut self, tabs: Arc<[Tab]>) -> Self {
171 self.tabs = tabs;
172 self
173 }
174
175 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 pub fn active(mut self, active: usize) -> Self {
185 self.active = active;
186 self
187 }
188
189 pub fn style(mut self, style: Style) -> Self {
191 self.style = style;
192 self
193 }
194
195 pub fn focus_style(mut self, style: Style) -> Self {
197 self.focus_style = StyleSlot::Replace(style);
198 self
199 }
200
201 pub fn extend_focus_style(mut self, style: Style) -> Self {
203 self.focus_style = StyleSlot::Extend(style);
204 self
205 }
206
207 pub fn inherit_focus_style(mut self) -> Self {
209 self.focus_style = StyleSlot::Inherit;
210 self
211 }
212
213 pub fn hover_style(mut self, style: Style) -> Self {
215 self.hover_style = StyleSlot::Replace(style);
216 self
217 }
218
219 pub fn extend_hover_style(mut self, style: Style) -> Self {
221 self.hover_style = StyleSlot::Extend(style);
222 self
223 }
224
225 pub fn inherit_hover_style(mut self) -> Self {
227 self.hover_style = StyleSlot::Inherit;
228 self
229 }
230
231 pub fn tab_hover_style(mut self, style: Style) -> Self {
233 self.tab_hover_style = StyleSlot::Replace(style);
234 self
235 }
236
237 pub fn extend_tab_hover_style(mut self, style: Style) -> Self {
239 self.tab_hover_style = StyleSlot::Extend(style);
240 self
241 }
242
243 pub fn inherit_tab_hover_style(mut self) -> Self {
245 self.tab_hover_style = StyleSlot::Inherit;
246 self
247 }
248
249 pub fn active_style(mut self, style: Style) -> Self {
251 self.active_style = StyleSlot::Replace(style);
252 self
253 }
254
255 pub fn extend_active_style(mut self, style: Style) -> Self {
257 self.active_style = StyleSlot::Extend(style);
258 self
259 }
260
261 pub fn inherit_active_style(mut self) -> Self {
263 self.active_style = StyleSlot::Inherit;
264 self
265 }
266
267 pub fn divider(mut self, ch: char) -> Self {
269 self.divider = ch;
270 self
271 }
272
273 pub fn caps(mut self, caps: Option<(char, char)>) -> Self {
287 self.caps = caps;
288 self
289 }
290
291 pub fn overflow(mut self, overflow: TabsOverflow) -> Self {
293 self.overflow = overflow;
294 self
295 }
296
297 pub fn border(mut self, border: bool) -> Self {
299 self.border = border;
300 self
301 }
302
303 pub fn border_style(mut self, border_style: BorderStyle) -> Self {
305 self.border_style = border_style;
306 self
307 }
308
309 pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
311 self.padding = padding.into();
312 self
313 }
314
315 pub fn width(mut self, width: Length) -> Self {
317 self.width = width;
318 self
319 }
320
321 pub fn height(mut self, height: Length) -> Self {
323 self.height = height;
324 self
325 }
326
327 pub fn on_change(mut self, cb: Callback<TabsEvent>) -> Self {
329 self.on_change = Some(cb);
330 self
331 }
332
333 pub fn on_click(mut self, cb: Callback<MouseEvent>) -> Self {
335 self.on_click = Some(cb);
336 self
337 }
338
339 pub fn on_key(mut self, handler: KeyHandler) -> Self {
341 self.on_key = Some(handler);
342 self
343 }
344
345 pub fn disabled(mut self, disabled: bool) -> Self {
347 self.disabled = disabled;
348 self
349 }
350
351 pub fn disabled_style(mut self, style: Style) -> Self {
353 self.disabled_style = style;
354 self
355 }
356
357 pub fn focusable(mut self, focusable: bool) -> Self {
359 self.focusable = focusable;
360 self
361 }
362
363 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
365 self.tab_stop = tab_stop;
366 self
367 }
368
369 pub fn on_focus(mut self, cb: Callback<()>) -> Self {
371 self.on_focus = Some(cb);
372 self
373 }
374
375 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
450pub(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
578pub(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; 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 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 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 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 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 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}