Skip to main content

tui_lipan/widgets/draggable_tab_bar/
mod.rs

1//! Draggable tab bar widget.
2
3mod layout;
4mod node;
5mod reconcile;
6
7pub use layout::measure_draggable_tab_bar;
8pub use node::DraggableTabBarNode;
9pub use reconcile::reconcile_draggable_tab_bar;
10
11use std::collections::HashMap;
12use std::path::Path;
13use std::sync::Arc;
14
15use crate::callback::{Callback, KeyHandler};
16use crate::core::element::{Element, ElementKind};
17use crate::core::event::MouseEvent;
18use crate::style::{BorderStyle, Color, FileIconPalette, Length, Padding, Span, Style, StyleSlot};
19use crate::utils::file_icons::FileIconOverride;
20use crate::utils::file_icons::file_icon;
21use crate::widgets::file_tree::FileIconStyle;
22use crate::widgets::{Spinner, TabsEvent, Text};
23use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
24
25/// Visual variant for [`DraggableTabBar`].
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
27pub enum DraggableTabBarVariant {
28    /// Classic segmented tabs with optional border.
29    #[default]
30    Bordered,
31    /// One-line frame-like tabs with left accent markers.
32    FrameLine,
33}
34
35/// Reorder behavior while dragging tabs.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
37pub enum DragReorderMode {
38    /// Emit reorder events as soon as drag crosses a tab boundary.
39    #[default]
40    Live,
41    /// Emit a single reorder event when mouse is released.
42    OnDrop,
43}
44
45/// Overflow behavior for a [`DraggableTabBar`].
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
47pub enum DraggableTabBarOverflow {
48    /// Keep natural tab widths and enable horizontal scrolling when tabs overflow.
49    #[default]
50    Scroll,
51    /// Shrink tab labels down to `min_tab_width` cells before enabling scrolling.
52    ShrinkThenScroll {
53        /// Minimum total tab width in terminal cells.
54        min_tab_width: u16,
55    },
56}
57
58/// Behavior kind for a [`DraggableTab`].
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
60pub enum DraggableTabKind {
61    /// A regular selectable, draggable tab.
62    #[default]
63    Tab,
64    /// A pinned action item inside the tab strip, such as a `+` new-tab button.
65    Action,
66}
67
68/// Event emitted when an action tab is clicked.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub struct DraggableTabActionEvent {
71    /// Action tab index in the rendered tab list.
72    pub index: usize,
73}
74
75/// Event emitted when a tab close button is clicked.
76#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
77pub struct DraggableTabCloseEvent {
78    /// Closed tab index.
79    pub index: usize,
80}
81
82/// Event emitted when a tab is reordered.
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
84pub struct DraggableTabReorderEvent {
85    /// Source tab index in the current order.
86    pub from: usize,
87    /// Destination tab index in the current order.
88    pub to: usize,
89}
90
91/// Event emitted when a tab is transferred to another connected bar.
92#[derive(Clone, Debug, PartialEq, Eq, Hash)]
93pub struct DraggableTabTransferEvent {
94    /// Source bar identifier.
95    pub from_bar: Arc<str>,
96    /// Destination bar identifier.
97    pub to_bar: Arc<str>,
98    /// Source index in `from_bar` before transfer.
99    pub from: usize,
100    /// Destination index in `to_bar` after transfer.
101    pub to: usize,
102}
103
104/// Which part of a tab was hit.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
106pub enum DraggableTabHitPart {
107    /// Main tab body.
108    Body,
109    /// Close affordance (`x`) area.
110    Close,
111}
112
113/// Hit-test result for a tab bar column.
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
115pub struct DraggableTabHit {
116    /// Tab index.
117    pub index: usize,
118    /// Hit part.
119    pub part: DraggableTabHitPart,
120}
121
122/// Inline content rendered before the tab label.
123#[derive(Clone, Debug)]
124pub(crate) enum TabLeadingContent {
125    Spinner(TabLeadingSpinner),
126    Text(Text),
127}
128
129#[derive(Clone, Debug)]
130pub(crate) struct TabLeadingSpinner {
131    pub spinner: Spinner,
132    pub auto_frame: bool,
133}
134
135impl TabLeadingContent {
136    pub(crate) fn from_element(element: Element) -> Self {
137        match element.kind {
138            ElementKind::Spinner(spinner) => Self::Spinner(TabLeadingSpinner {
139                auto_frame: spinner.frame.is_none(),
140                spinner,
141            }),
142            ElementKind::Text(text) => Self::Text(text),
143            _ => panic!("DraggableTab::leading only supports Spinner or Text elements"),
144        }
145    }
146
147    pub(crate) fn spinner_mut(&mut self) -> Option<&mut TabLeadingSpinner> {
148        match self {
149            Self::Spinner(spinner) => Some(spinner),
150            Self::Text(_) => None,
151        }
152    }
153
154    pub(crate) fn spinner_frame(&self) -> Option<usize> {
155        match self {
156            Self::Spinner(spinner) => spinner.spinner.frame,
157            Self::Text(_) => None,
158        }
159    }
160
161    pub(crate) fn has_spinner(&self) -> bool {
162        matches!(self, Self::Spinner(_))
163    }
164
165    pub(crate) fn to_span(&self) -> Span {
166        match self {
167            Self::Spinner(spinner) => {
168                let frames = spinner.spinner.spinner_style.frames();
169                let frame_str = frames[spinner.spinner.frame.unwrap_or(0) % frames.len()];
170                let mut span = Span::new(frame_str);
171                span.style = spinner.spinner.style;
172                span
173            }
174            Self::Text(text) => {
175                let mut span = Span::new(text.plain_content());
176                let span_style = text
177                    .spans
178                    .first()
179                    .map(|span| span.style)
180                    .unwrap_or_default();
181                span.style = text.style.patch(span_style);
182                span
183            }
184        }
185    }
186}
187
188/// A single draggable tab item.
189#[derive(Clone, Debug)]
190pub struct DraggableTab {
191    pub(crate) label: Arc<str>,
192    pub(crate) kind: DraggableTabKind,
193    pub(crate) style: Style,
194    pub(crate) hover_style: Style,
195    pub(crate) active_style: Style,
196    pub(crate) accent_style: Style,
197    pub(crate) active_accent_style: Style,
198    pub(crate) closeable: bool,
199    pub(crate) icon: Option<Span>,
200    pub(crate) leading: Option<TabLeadingContent>,
201    pub(crate) path: Option<Arc<str>>,
202    pub(crate) right_badge: Option<Span>,
203}
204
205impl DraggableTab {
206    /// Create a tab with label.
207    pub fn new(label: impl Into<Arc<str>>) -> Self {
208        Self {
209            label: label.into(),
210            kind: DraggableTabKind::Tab,
211            style: Style::default(),
212            hover_style: Style::default(),
213            active_style: Style::default(),
214            accent_style: Style::default(),
215            active_accent_style: Style::default(),
216            closeable: false,
217            icon: None,
218            leading: None,
219            path: None,
220            right_badge: None,
221        }
222    }
223
224    /// Create a pinned action tab, such as a `+` new-tab button.
225    ///
226    /// Action tabs emit [`DraggableTabBar::on_action`] instead of changing the
227    /// active tab, and do not participate in drag reordering.
228    pub fn action(label: impl Into<Arc<str>>) -> Self {
229        Self::new(label).kind(DraggableTabKind::Action)
230    }
231
232    /// Set tab behavior kind.
233    pub fn kind(mut self, kind: DraggableTabKind) -> Self {
234        self.kind = kind;
235        self
236    }
237
238    /// Set tab style.
239    pub fn style(mut self, style: Style) -> Self {
240        self.style = style;
241        self
242    }
243
244    /// Set this tab's hover style.
245    ///
246    /// This patches over [`DraggableTabBar::tab_hover_style`] for inactive tabs.
247    /// Active tabs keep active styling and do not receive hover styling.
248    pub fn hover_style(mut self, style: Style) -> Self {
249        self.hover_style = style;
250        self
251    }
252
253    /// Set this tab's active style.
254    ///
255    /// This patches over [`DraggableTabBar::active_style`] when this tab is selected.
256    pub fn active_style(mut self, style: Style) -> Self {
257        self.active_style = style;
258        self
259    }
260
261    /// Set this tab's accent style for the `FrameLine` variant.
262    ///
263    /// This patches over [`DraggableTabBar::accent_style`] for this tab's accent marker.
264    pub fn accent_style(mut self, style: Style) -> Self {
265        self.accent_style = style;
266        self
267    }
268
269    /// Set this tab's active accent style for the `FrameLine` variant.
270    ///
271    /// This patches over [`DraggableTabBar::active_accent_style`] when this tab is selected.
272    pub fn active_accent_style(mut self, style: Style) -> Self {
273        self.active_accent_style = style;
274        self
275    }
276
277    /// Enable or disable close affordance for this tab.
278    pub fn closeable(mut self, closeable: bool) -> Self {
279        self.closeable = closeable;
280        self
281    }
282
283    /// Set a custom icon rendered before the label.
284    pub fn icon(mut self, icon: impl Into<Span>) -> Self {
285        self.icon = Some(icon.into());
286        self
287    }
288
289    /// Set inline content rendered before the label (replaces icon when set).
290    ///
291    /// Supports [`Spinner`] and [`Text`] elements. Spinner label/layout
292    /// properties and text layout properties are ignored because the tab owns
293    /// its own label and sizing.
294    pub fn leading(mut self, leading: Element) -> Self {
295        self.leading = Some(TabLeadingContent::from_element(leading));
296        self
297    }
298
299    /// Set file path used for automatic file-icon resolution.
300    pub fn path(mut self, path: impl Into<Arc<str>>) -> Self {
301        self.path = Some(path.into());
302        self
303    }
304
305    /// Set a generic right-side badge rendered after the label.
306    pub fn right_badge(mut self, badge: impl Into<Span>) -> Self {
307        self.right_badge = Some(badge.into());
308        self
309    }
310}
311
312impl From<&'static str> for DraggableTab {
313    fn from(value: &'static str) -> Self {
314        Self::new(value)
315    }
316}
317
318impl From<String> for DraggableTab {
319    fn from(value: String) -> Self {
320        Self::new(value)
321    }
322}
323
324impl From<Arc<str>> for DraggableTab {
325    fn from(value: Arc<str>) -> Self {
326        Self::new(value)
327    }
328}
329
330#[derive(Clone, Copy, Debug)]
331pub(crate) struct TabMetrics {
332    pub width: usize,
333    pub close_start: Option<usize>,
334    pub close_end: Option<usize>,
335    pub label_width: usize,
336}
337
338#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
339pub(crate) enum OverflowControlSide {
340    Left,
341    Right,
342}
343
344#[derive(Clone, Debug)]
345pub(crate) struct OverflowControl {
346    pub start: usize,
347    pub end: usize,
348    pub label: Arc<str>,
349}
350
351/// Builds the label rendered on an overflow control from the hidden tab count.
352pub(crate) type OverflowLabelFormatter = Arc<dyn Fn(usize) -> Arc<str>>;
353
354/// Label formatters for the left/right overflow controls.
355///
356/// `None` on a side keeps the default Nerd Font label for that side.
357#[derive(Clone, Default)]
358pub(crate) struct OverflowLabels {
359    pub left: Option<OverflowLabelFormatter>,
360    pub right: Option<OverflowLabelFormatter>,
361}
362
363impl OverflowLabels {
364    pub(crate) fn label(&self, side: OverflowControlSide, hidden_count: usize) -> Arc<str> {
365        let custom = match side {
366            OverflowControlSide::Left => self.left.as_ref(),
367            OverflowControlSide::Right => self.right.as_ref(),
368        };
369        match custom {
370            Some(format) => format(hidden_count),
371            None => default_overflow_control_label(side, hidden_count),
372        }
373    }
374
375    pub(crate) fn width(&self, side: OverflowControlSide, hidden_count: usize) -> usize {
376        UnicodeWidthStr::width(self.label(side, hidden_count).as_ref())
377    }
378}
379
380#[derive(Clone, Debug)]
381pub(crate) struct VisibleTab {
382    pub index: usize,
383    pub start: usize,
384    pub end: usize,
385    pub metrics: TabMetrics,
386    pub clip_left: usize,
387}
388
389#[derive(Clone, Debug)]
390pub(crate) struct TabViewportLayout {
391    pub offset: usize,
392    pub visible_tabs: Vec<VisibleTab>,
393    pub hidden_left: usize,
394    pub hidden_right: usize,
395    pub content_start: usize,
396    pub content_width: usize,
397    pub left_control: Option<OverflowControl>,
398    pub right_control: Option<OverflowControl>,
399}
400
401pub(crate) const TAB_SCROLL_STEP_CHARS: usize = 12;
402pub(crate) const TAB_SCROLL_BUTTON_STEP_CHARS: usize = TAB_SCROLL_STEP_CHARS * 2;
403
404pub(crate) struct TabDisplayOptions<'a> {
405    pub variant: DraggableTabBarVariant,
406    pub divider: char,
407    pub accent_symbol: char,
408    pub close_symbol: &'a str,
409    pub show_close_buttons: bool,
410    pub tab_max_width: Option<u16>,
411    pub overflow: DraggableTabBarOverflow,
412    pub show_file_icons: bool,
413    pub file_icon_style: FileIconStyle,
414    pub file_icon_palette: &'a FileIconPalette,
415    pub file_icon_overrides: &'a HashMap<Arc<str>, FileIconOverride>,
416    pub width_lock: Option<TabWidthLock>,
417}
418
419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
420pub(crate) struct TabWidthLock {
421    pub index: usize,
422    pub width: usize,
423}
424
425#[derive(Clone, Default)]
426pub(crate) struct TabViewportOptions {
427    pub scroll_offset: usize,
428    pub viewport_width: usize,
429    pub show_overflow_controls: bool,
430    pub overflow_labels: OverflowLabels,
431}
432
433#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
434pub(crate) enum DraggableTabHitTarget {
435    Tab(DraggableTabHit),
436    Overflow(OverflowControlSide),
437}
438
439/// A draggable tab bar suitable for editor-like UIs.
440#[derive(Clone)]
441pub struct DraggableTabBar {
442    pub(crate) tabs: Arc<[DraggableTab]>,
443    pub(crate) active: usize,
444    pub(crate) style: Style,
445    pub(crate) focus_style: StyleSlot,
446    pub(crate) hover_style: StyleSlot,
447    pub(crate) tab_hover_style: StyleSlot,
448    pub(crate) active_style: StyleSlot,
449    pub(crate) close_style: Style,
450    pub(crate) close_hover_style: Style,
451    pub(crate) divider: char,
452    pub(crate) border: bool,
453    pub(crate) border_style: BorderStyle,
454    pub(crate) padding: Padding,
455    pub(crate) width: Length,
456    pub(crate) height: Length,
457    pub(crate) variant: DraggableTabBarVariant,
458    pub(crate) accent_symbol: char,
459    pub(crate) active_accent_symbol: char,
460    pub(crate) accent_style: Style,
461    pub(crate) active_accent_style: Style,
462    pub(crate) close_symbol: Arc<str>,
463    pub(crate) show_close_buttons: bool,
464    pub(crate) close_on_hover_only: bool,
465    pub(crate) tab_max_width: Option<u16>,
466    pub(crate) overflow: DraggableTabBarOverflow,
467    pub(crate) scroll_wheel: bool,
468    pub(crate) show_overflow_controls: bool,
469    pub(crate) overflow_style: Style,
470    pub(crate) overflow_hover_style: Style,
471    pub(crate) overflow_labels: OverflowLabels,
472    pub(crate) empty_text: Option<Arc<str>>,
473    pub(crate) empty_text_style: Style,
474    pub(crate) scroll_offset: usize,
475    pub(crate) show_file_icons: bool,
476    pub(crate) file_icon_style: FileIconStyle,
477    pub(crate) file_icon_palette: FileIconPalette,
478    pub(crate) file_icon_overrides: HashMap<Arc<str>, FileIconOverride>,
479    pub(crate) bar_id: Option<Arc<str>>,
480    pub(crate) drag_group: Option<Arc<str>>,
481    pub(crate) draggable: bool,
482    pub(crate) drag_preview: bool,
483    pub(crate) reorder_mode: DragReorderMode,
484    pub(crate) drag_threshold: u16,
485    pub(crate) on_change: Option<Callback<TabsEvent>>,
486    pub(crate) on_action: Option<Callback<DraggableTabActionEvent>>,
487    pub(crate) on_close: Option<Callback<DraggableTabCloseEvent>>,
488    pub(crate) on_reorder: Option<Callback<DraggableTabReorderEvent>>,
489    pub(crate) on_transfer: Option<Callback<DraggableTabTransferEvent>>,
490    pub(crate) on_click: Option<Callback<MouseEvent>>,
491    pub(crate) on_key: Option<KeyHandler>,
492    pub(crate) disabled: bool,
493    pub(crate) disabled_style: Style,
494    pub(crate) focusable: bool,
495    pub(crate) tab_stop: bool,
496    pub(crate) on_focus: Option<Callback<()>>,
497    pub(crate) on_blur: Option<Callback<()>>,
498}
499
500impl Default for DraggableTabBar {
501    fn default() -> Self {
502        Self {
503            tabs: Arc::new([]),
504            active: 0,
505            style: Style::default(),
506            focus_style: StyleSlot::Inherit,
507            hover_style: StyleSlot::Inherit,
508            tab_hover_style: StyleSlot::Inherit,
509            active_style: StyleSlot::Inherit,
510            close_style: Style::default(),
511            close_hover_style: Style::default(),
512            divider: '│',
513            border: false,
514            border_style: BorderStyle::Plain,
515            padding: Padding::default(),
516            width: Length::Flex(1),
517            height: Length::Auto,
518            variant: DraggableTabBarVariant::Bordered,
519            accent_symbol: '▏',
520            active_accent_symbol: '▎',
521            accent_style: Style::default(),
522            active_accent_style: Style::default(),
523            close_symbol: Arc::from(""),
524            show_close_buttons: true,
525            close_on_hover_only: false,
526            tab_max_width: None,
527            overflow: DraggableTabBarOverflow::Scroll,
528            scroll_wheel: true,
529            show_overflow_controls: true,
530            overflow_style: Style::default(),
531            overflow_hover_style: Style::default(),
532            overflow_labels: OverflowLabels::default(),
533            empty_text: None,
534            empty_text_style: Style::default(),
535            scroll_offset: 0,
536            show_file_icons: false,
537            file_icon_style: FileIconStyle::NerdFont,
538            file_icon_palette: FileIconPalette::default(),
539            file_icon_overrides: HashMap::new(),
540            bar_id: None,
541            drag_group: None,
542            draggable: true,
543            drag_preview: true,
544            reorder_mode: DragReorderMode::Live,
545            drag_threshold: 1,
546            on_change: None,
547            on_action: None,
548            on_close: None,
549            on_reorder: None,
550            on_transfer: None,
551            on_click: None,
552            on_key: None,
553            disabled: false,
554            disabled_style: Style::default(),
555            focusable: false,
556            tab_stop: true,
557            on_focus: None,
558            on_blur: None,
559        }
560    }
561}
562
563impl DraggableTabBar {
564    /// Create an empty draggable tab bar.
565    pub fn new() -> Self {
566        Self::default()
567    }
568
569    pub(crate) fn display_options(&self) -> TabDisplayOptions<'_> {
570        TabDisplayOptions {
571            variant: self.variant,
572            divider: self.divider,
573            accent_symbol: self.accent_symbol,
574            close_symbol: &self.close_symbol,
575            show_close_buttons: self.show_close_buttons,
576            tab_max_width: self.tab_max_width,
577            overflow: self.overflow,
578            show_file_icons: self.show_file_icons,
579            file_icon_style: self.file_icon_style,
580            file_icon_palette: &self.file_icon_palette,
581            file_icon_overrides: &self.file_icon_overrides,
582            width_lock: None,
583        }
584    }
585
586    /// Replace tabs.
587    pub fn tabs<I>(mut self, tabs: I) -> Self
588    where
589        I: IntoIterator<Item = DraggableTab>,
590    {
591        self.tabs = tabs.into_iter().collect::<Vec<_>>().into();
592        self
593    }
594
595    /// Add one tab.
596    pub fn tab(mut self, tab: impl Into<DraggableTab>) -> Self {
597        let mut tabs = self.tabs.to_vec();
598        tabs.push(tab.into());
599        self.tabs = tabs.into();
600        self
601    }
602
603    /// Set active tab index.
604    pub fn active(mut self, active: usize) -> Self {
605        self.active = active;
606        self
607    }
608
609    /// Set base style.
610    pub fn style(mut self, style: Style) -> Self {
611        self.style = style;
612        self
613    }
614
615    /// Set focus style for the whole widget.
616    pub fn focus_style(mut self, style: Style) -> Self {
617        self.focus_style = StyleSlot::Replace(style);
618        self
619    }
620
621    /// Extend the active theme's focus style with additional fields.
622    pub fn extend_focus_style(mut self, style: Style) -> Self {
623        self.focus_style = StyleSlot::Extend(style);
624        self
625    }
626
627    /// Inherit focus style from the active theme.
628    pub fn inherit_focus_style(mut self) -> Self {
629        self.focus_style = StyleSlot::Inherit;
630        self
631    }
632
633    /// Set hover style for the whole widget.
634    pub fn hover_style(mut self, style: Style) -> Self {
635        self.hover_style = StyleSlot::Replace(style);
636        self
637    }
638
639    /// Extend the active theme's hover style with additional fields.
640    pub fn extend_hover_style(mut self, style: Style) -> Self {
641        self.hover_style = StyleSlot::Extend(style);
642        self
643    }
644
645    /// Inherit hover style from the active theme.
646    pub fn inherit_hover_style(mut self) -> Self {
647        self.hover_style = StyleSlot::Inherit;
648        self
649    }
650
651    /// Set style for hovered tab.
652    pub fn tab_hover_style(mut self, style: Style) -> Self {
653        self.tab_hover_style = StyleSlot::Replace(style);
654        self
655    }
656
657    /// Extend the active theme's tab hover style with additional fields.
658    pub fn extend_tab_hover_style(mut self, style: Style) -> Self {
659        self.tab_hover_style = StyleSlot::Extend(style);
660        self
661    }
662
663    /// Inherit tab hover style from the active theme.
664    pub fn inherit_tab_hover_style(mut self) -> Self {
665        self.tab_hover_style = StyleSlot::Inherit;
666        self
667    }
668
669    /// Set active tab style.
670    pub fn active_style(mut self, style: Style) -> Self {
671        self.active_style = StyleSlot::Replace(style);
672        self
673    }
674
675    /// Extend the active theme's active-tab style with additional fields.
676    pub fn extend_active_style(mut self, style: Style) -> Self {
677        self.active_style = StyleSlot::Extend(style);
678        self
679    }
680
681    /// Inherit active-tab style from the active theme.
682    pub fn inherit_active_style(mut self) -> Self {
683        self.active_style = StyleSlot::Inherit;
684        self
685    }
686
687    /// Set close symbol style.
688    pub fn close_style(mut self, style: Style) -> Self {
689        self.close_style = style;
690        self
691    }
692
693    /// Set close symbol hover style.
694    pub fn close_hover_style(mut self, style: Style) -> Self {
695        self.close_hover_style = style;
696        self
697    }
698
699    /// Set divider character for bordered variant.
700    pub fn divider(mut self, ch: char) -> Self {
701        self.divider = ch;
702        self
703    }
704
705    /// Draw outer border.
706    pub fn border(mut self, border: bool) -> Self {
707        self.border = border;
708        self
709    }
710
711    /// Set border style.
712    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
713        self.border_style = border_style;
714        self
715    }
716
717    /// Set padding.
718    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
719        self.padding = padding.into();
720        self
721    }
722
723    /// Set requested width.
724    pub fn width(mut self, width: Length) -> Self {
725        self.width = width;
726        self
727    }
728
729    /// Set requested height.
730    pub fn height(mut self, height: Length) -> Self {
731        self.height = height;
732        self
733    }
734
735    /// Set visual variant.
736    pub fn variant(mut self, variant: DraggableTabBarVariant) -> Self {
737        self.variant = variant;
738        self
739    }
740
741    /// Set left accent symbol for frame-line variant.
742    pub fn accent_symbol(mut self, symbol: char) -> Self {
743        self.accent_symbol = symbol;
744        self
745    }
746
747    /// Set active tab accent symbol for frame-line variant.
748    pub fn active_accent_symbol(mut self, symbol: char) -> Self {
749        self.active_accent_symbol = symbol;
750        self
751    }
752
753    /// Set inactive accent style for frame-line variant.
754    pub fn accent_style(mut self, style: Style) -> Self {
755        self.accent_style = style;
756        self
757    }
758
759    /// Set active accent style for frame-line variant.
760    pub fn active_accent_style(mut self, style: Style) -> Self {
761        self.active_accent_style = style;
762        self
763    }
764
765    /// Set close symbol.
766    pub fn close_symbol(mut self, symbol: impl Into<Arc<str>>) -> Self {
767        self.close_symbol = symbol.into();
768        self
769    }
770
771    /// Toggle rendering of close buttons for closeable tabs.
772    pub fn show_close_buttons(mut self, show: bool) -> Self {
773        self.show_close_buttons = show;
774        self
775    }
776
777    /// Show close symbols only while the tab is hovered.
778    ///
779    /// Layout width remains stable (close slot is reserved) to avoid jitter.
780    pub fn close_on_hover_only(mut self, only_on_hover: bool) -> Self {
781        self.close_on_hover_only = only_on_hover;
782        self
783    }
784
785    /// Clamp per-tab label width, truncating with right-side ellipsis.
786    pub fn tab_max_width(mut self, width: Option<u16>) -> Self {
787        self.tab_max_width = width;
788        self
789    }
790
791    /// Set tab overflow behavior.
792    pub fn overflow(mut self, overflow: DraggableTabBarOverflow) -> Self {
793        self.overflow = overflow;
794        self
795    }
796
797    /// Enable mouse wheel horizontal scrolling.
798    pub fn scroll_wheel(mut self, enabled: bool) -> Self {
799        self.scroll_wheel = enabled;
800        self
801    }
802
803    /// Show overflow controls when tabs are clipped horizontally.
804    pub fn show_overflow_controls(mut self, show: bool) -> Self {
805        self.show_overflow_controls = show;
806        self
807    }
808
809    /// Set style for overflow controls.
810    pub fn overflow_style(mut self, style: Style) -> Self {
811        self.overflow_style = style;
812        self
813    }
814
815    /// Set hover style for overflow controls.
816    pub fn overflow_hover_style(mut self, style: Style) -> Self {
817        self.overflow_hover_style = style;
818        self
819    }
820
821    /// Set a custom label for the left overflow control.
822    ///
823    /// The formatter receives the number of tabs hidden to the left. Any padding
824    /// around the glyphs must be part of the returned string, and the rendered
825    /// width is measured from it, so wider labels shrink the tab area accordingly.
826    pub fn overflow_left_label<F>(mut self, formatter: F) -> Self
827    where
828        F: Fn(usize) -> Arc<str> + 'static,
829    {
830        self.overflow_labels.left = Some(Arc::new(formatter));
831        self
832    }
833
834    /// Set a custom label for the right overflow control.
835    ///
836    /// The formatter receives the number of tabs hidden to the right. See
837    /// [`DraggableTabBar::overflow_left_label`] for padding and width semantics.
838    pub fn overflow_right_label<F>(mut self, formatter: F) -> Self
839    where
840        F: Fn(usize) -> Arc<str> + 'static,
841    {
842        self.overflow_labels.right = Some(Arc::new(formatter));
843        self
844    }
845
846    /// Set placeholder text shown when the bar has no tabs.
847    ///
848    /// The placeholder is left-aligned inside the bar's padding, truncated with an
849    /// ellipsis when wider than the available width, and is not interactive. Leave
850    /// unset (`None`) to keep the empty bar blank aside from its border/background.
851    pub fn empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
852        self.empty_text = Some(text.into());
853        self
854    }
855
856    /// Set style for the empty-state placeholder text.
857    ///
858    /// Patched onto the resolved bar style (including disabled/focus/hover).
859    pub fn empty_text_style(mut self, style: Style) -> Self {
860        self.empty_text_style = style;
861        self
862    }
863
864    /// Set initial horizontal tab scroll offset (tab index).
865    pub fn scroll_offset(mut self, offset: usize) -> Self {
866        self.scroll_offset = offset;
867        self
868    }
869
870    /// Enable automatic file icons before tab titles.
871    pub fn show_file_icons(mut self, show: bool) -> Self {
872        self.show_file_icons = show;
873        self
874    }
875
876    /// Set file icon style used by automatic tab icons.
877    pub fn file_icon_style(mut self, style: FileIconStyle) -> Self {
878        self.file_icon_style = style;
879        self
880    }
881
882    /// Set file icon palette used by automatic tab icons.
883    pub fn file_icon_palette(mut self, palette: FileIconPalette) -> Self {
884        self.file_icon_palette = palette;
885        self
886    }
887
888    /// Add file icon override by filename or extension.
889    pub fn file_icon_override(
890        mut self,
891        pattern: impl Into<Arc<str>>,
892        icon: impl Into<Arc<str>>,
893        color: Option<Color>,
894    ) -> Self {
895        self.file_icon_overrides.insert(
896            pattern.into(),
897            FileIconOverride {
898                icon: icon.into(),
899                color,
900            },
901        );
902        self
903    }
904
905    /// Set a stable identifier for this tab bar.
906    ///
907    /// Required for cross-bar tab transfers.
908    pub fn bar_id(mut self, id: impl Into<Arc<str>>) -> Self {
909        self.bar_id = Some(id.into());
910        self
911    }
912
913    /// Set drag group for cross-bar transfers.
914    ///
915    /// Tabs can transfer only between bars with the same group.
916    pub fn drag_group(mut self, group: impl Into<Arc<str>>) -> Self {
917        self.drag_group = Some(group.into());
918        self
919    }
920
921    /// Toggle drag reordering.
922    pub fn draggable(mut self, draggable: bool) -> Self {
923        self.draggable = draggable;
924        self
925    }
926
927    /// Show a floating label near the pointer while dragging a tab (default: `true`).
928    pub fn drag_preview(mut self, enabled: bool) -> Self {
929        self.drag_preview = enabled;
930        self
931    }
932
933    /// Set drag reorder mode.
934    pub fn reorder_mode(mut self, mode: DragReorderMode) -> Self {
935        self.reorder_mode = mode;
936        self
937    }
938
939    /// Set drag start threshold in columns.
940    pub fn drag_threshold(mut self, threshold: u16) -> Self {
941        self.drag_threshold = threshold;
942        self
943    }
944
945    /// Callback fired when active tab changes.
946    pub fn on_change(mut self, cb: Callback<TabsEvent>) -> Self {
947        self.on_change = Some(cb);
948        self
949    }
950
951    /// Callback fired when an action tab is clicked.
952    pub fn on_action(mut self, cb: Callback<DraggableTabActionEvent>) -> Self {
953        self.on_action = Some(cb);
954        self
955    }
956
957    /// Callback fired when a close button is clicked.
958    pub fn on_close(mut self, cb: Callback<DraggableTabCloseEvent>) -> Self {
959        self.on_close = Some(cb);
960        self
961    }
962
963    /// Callback fired when tab order changes.
964    pub fn on_reorder(mut self, cb: Callback<DraggableTabReorderEvent>) -> Self {
965        self.on_reorder = Some(cb);
966        self
967    }
968
969    /// Callback fired when a tab is moved between connected bars.
970    pub fn on_transfer(mut self, cb: Callback<DraggableTabTransferEvent>) -> Self {
971        self.on_transfer = Some(cb);
972        self
973    }
974
975    /// Set on-click handler.
976    pub fn on_click(mut self, cb: Callback<MouseEvent>) -> Self {
977        self.on_click = Some(cb);
978        self
979    }
980
981    /// Set on-key handler.
982    pub fn on_key(mut self, handler: KeyHandler) -> Self {
983        self.on_key = Some(handler);
984        self
985    }
986
987    /// Set disabled state.
988    pub fn disabled(mut self, disabled: bool) -> Self {
989        self.disabled = disabled;
990        self
991    }
992
993    /// Set disabled style.
994    pub fn disabled_style(mut self, style: Style) -> Self {
995        self.disabled_style = style;
996        self
997    }
998
999    /// Control whether node is focusable.
1000    pub fn focusable(mut self, focusable: bool) -> Self {
1001        self.focusable = focusable;
1002        self
1003    }
1004
1005    /// Control whether the node participates in tab traversal.
1006    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
1007        self.tab_stop = tab_stop;
1008        self
1009    }
1010
1011    /// Set the callback fired when the node gains focus.
1012    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
1013        self.on_focus = Some(cb);
1014        self
1015    }
1016
1017    /// Set the callback fired when the node loses focus.
1018    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
1019        self.on_blur = Some(cb);
1020        self
1021    }
1022
1023    #[cfg(test)]
1024    pub(crate) fn content_width(
1025        tabs: &[DraggableTab],
1026        variant: DraggableTabBarVariant,
1027        divider: char,
1028        accent_symbol: char,
1029        close_symbol: &str,
1030        show_close_buttons: bool,
1031    ) -> usize {
1032        Self::content_width_with_options(
1033            tabs,
1034            &TabDisplayOptions {
1035                variant,
1036                divider,
1037                accent_symbol,
1038                close_symbol,
1039                show_close_buttons,
1040                tab_max_width: None,
1041                overflow: DraggableTabBarOverflow::Scroll,
1042                show_file_icons: false,
1043                file_icon_style: FileIconStyle::NerdFont,
1044                file_icon_palette: &FileIconPalette::default(),
1045                file_icon_overrides: &HashMap::new(),
1046                width_lock: None,
1047            },
1048        )
1049    }
1050
1051    pub(crate) fn content_width_with_options(
1052        tabs: &[DraggableTab],
1053        opts: &TabDisplayOptions<'_>,
1054    ) -> usize {
1055        let mut width = 0usize;
1056        for (i, tab) in tabs.iter().enumerate() {
1057            width = width.saturating_add(tab_metrics_with_options(tab, opts).width);
1058            if i + 1 < tabs.len() {
1059                width = width.saturating_add(separator_width(opts.variant, opts.divider));
1060            }
1061        }
1062        width
1063    }
1064
1065    pub(crate) fn content_width_for_viewport(
1066        tabs: &[DraggableTab],
1067        opts: &TabDisplayOptions<'_>,
1068        viewport_width: usize,
1069    ) -> usize {
1070        let metrics = tab_metrics_for_viewport(tabs, opts, Some(viewport_width));
1071        total_width_for_metrics(&metrics, opts)
1072    }
1073
1074    #[cfg(test)]
1075    pub(crate) fn hit_at_col(
1076        tabs: &[DraggableTab],
1077        variant: DraggableTabBarVariant,
1078        divider: char,
1079        accent_symbol: char,
1080        close_symbol: &str,
1081        show_close_buttons: bool,
1082        col: usize,
1083    ) -> Option<DraggableTabHit> {
1084        Self::hit_at_col_with_options(
1085            tabs,
1086            &TabDisplayOptions {
1087                variant,
1088                divider,
1089                accent_symbol,
1090                close_symbol,
1091                show_close_buttons,
1092                tab_max_width: None,
1093                overflow: DraggableTabBarOverflow::Scroll,
1094                show_file_icons: false,
1095                file_icon_style: FileIconStyle::NerdFont,
1096                file_icon_palette: &FileIconPalette::default(),
1097                file_icon_overrides: &HashMap::new(),
1098                width_lock: None,
1099            },
1100            col,
1101        )
1102    }
1103
1104    #[cfg(test)]
1105    pub(crate) fn hit_at_col_with_options(
1106        tabs: &[DraggableTab],
1107        opts: &TabDisplayOptions<'_>,
1108        col: usize,
1109    ) -> Option<DraggableTabHit> {
1110        let mut x = 0usize;
1111        for (i, tab) in tabs.iter().enumerate() {
1112            let metrics = tab_metrics_with_options(tab, opts);
1113            let end = x.saturating_add(metrics.width);
1114            if col < end {
1115                let part = if let (Some(close_start), Some(close_end)) =
1116                    (metrics.close_start, metrics.close_end)
1117                {
1118                    let c_start = x.saturating_add(close_start);
1119                    let c_end = x.saturating_add(close_end);
1120                    if col >= c_start && col < c_end {
1121                        DraggableTabHitPart::Close
1122                    } else {
1123                        DraggableTabHitPart::Body
1124                    }
1125                } else {
1126                    DraggableTabHitPart::Body
1127                };
1128                return Some(DraggableTabHit { index: i, part });
1129            }
1130            x = end;
1131
1132            if i + 1 < tabs.len() {
1133                let sep_w = separator_width(opts.variant, opts.divider);
1134                if col < x.saturating_add(sep_w) {
1135                    return match opts.variant {
1136                        DraggableTabBarVariant::Bordered => None,
1137                        DraggableTabBarVariant::FrameLine => Some(DraggableTabHit {
1138                            index: i,
1139                            part: DraggableTabHitPart::Body,
1140                        }),
1141                    };
1142                }
1143                x = x.saturating_add(sep_w);
1144            }
1145        }
1146
1147        None
1148    }
1149
1150    #[cfg(test)]
1151    pub(crate) fn reorder_index_at_col(
1152        tabs: &[DraggableTab],
1153        variant: DraggableTabBarVariant,
1154        divider: char,
1155        accent_symbol: char,
1156        close_symbol: &str,
1157        show_close_buttons: bool,
1158        col: usize,
1159    ) -> Option<usize> {
1160        Self::reorder_index_at_col_with_options(
1161            tabs,
1162            &TabDisplayOptions {
1163                variant,
1164                divider,
1165                accent_symbol,
1166                close_symbol,
1167                show_close_buttons,
1168                tab_max_width: None,
1169                overflow: DraggableTabBarOverflow::Scroll,
1170                show_file_icons: false,
1171                file_icon_style: FileIconStyle::NerdFont,
1172                file_icon_palette: &FileIconPalette::default(),
1173                file_icon_overrides: &HashMap::new(),
1174                width_lock: None,
1175            },
1176            col,
1177        )
1178    }
1179
1180    #[cfg(test)]
1181    pub(crate) fn reorder_index_at_col_with_options(
1182        tabs: &[DraggableTab],
1183        opts: &TabDisplayOptions<'_>,
1184        col: usize,
1185    ) -> Option<usize> {
1186        let metrics = tab_metrics_for_viewport(tabs, opts, None);
1187        Self::reorder_index_at_col_with_metrics(tabs, opts, &metrics, col)
1188    }
1189
1190    fn reorder_index_at_col_with_metrics(
1191        tabs: &[DraggableTab],
1192        opts: &TabDisplayOptions<'_>,
1193        metrics: &[TabMetrics],
1194        col: usize,
1195    ) -> Option<usize> {
1196        let mut x = 0usize;
1197        let len = tabs.len();
1198        for (i, metrics) in metrics.iter().enumerate() {
1199            let end = x.saturating_add(metrics.width);
1200            if col < end {
1201                return Some(i);
1202            }
1203            x = end;
1204
1205            if i + 1 < len {
1206                let sep_w = separator_width(opts.variant, opts.divider);
1207                if col < x.saturating_add(sep_w) {
1208                    return Some((i + 1).min(len.saturating_sub(1)));
1209                }
1210                x = x.saturating_add(sep_w);
1211            }
1212        }
1213        None
1214    }
1215
1216    #[cfg(test)]
1217    pub(crate) fn adjacent_reorder_target(
1218        tabs: &[DraggableTab],
1219        opts: &TabDisplayOptions<'_>,
1220        current_index: usize,
1221        col: usize,
1222    ) -> Option<usize> {
1223        Self::adjacent_reorder_target_with_options(tabs, opts, current_index, col)
1224    }
1225
1226    #[cfg(test)]
1227    pub(crate) fn adjacent_reorder_target_with_options(
1228        tabs: &[DraggableTab],
1229        opts: &TabDisplayOptions<'_>,
1230        current_index: usize,
1231        col: usize,
1232    ) -> Option<usize> {
1233        let metrics = tab_metrics_for_viewport(tabs, opts, None);
1234        Self::adjacent_reorder_target_with_metrics(tabs, opts, &metrics, current_index, col)
1235    }
1236
1237    fn adjacent_reorder_target_with_metrics(
1238        tabs: &[DraggableTab],
1239        opts: &TabDisplayOptions<'_>,
1240        metrics: &[TabMetrics],
1241        current_index: usize,
1242        col: usize,
1243    ) -> Option<usize> {
1244        if tabs.is_empty() || current_index >= tabs.len() {
1245            return None;
1246        }
1247
1248        if !tabs.get(current_index).is_some_and(is_reorderable_tab) {
1249            return None;
1250        }
1251
1252        let mut starts = Vec::with_capacity(tabs.len());
1253        let mut widths = Vec::with_capacity(tabs.len());
1254        let mut reorder_indices = Vec::new();
1255        let mut x = 0usize;
1256        for (i, (tab, metrics)) in tabs.iter().zip(metrics).enumerate() {
1257            starts.push(x);
1258            widths.push(metrics.width);
1259            if is_reorderable_tab(tab) {
1260                reorder_indices.push(i);
1261            }
1262            x = x.saturating_add(metrics.width);
1263            if i + 1 < tabs.len() {
1264                x = x.saturating_add(separator_width(opts.variant, opts.divider));
1265            }
1266        }
1267
1268        let mut position = reorder_indices
1269            .iter()
1270            .position(|&index| index == current_index)?;
1271        let mut run_start = position;
1272        while run_start > 0 && reorder_indices[run_start - 1] + 1 == reorder_indices[run_start] {
1273            run_start -= 1;
1274        }
1275        let mut run_end = position;
1276        while run_end + 1 < reorder_indices.len()
1277            && reorder_indices[run_end] + 1 == reorder_indices[run_end + 1]
1278        {
1279            run_end += 1;
1280        }
1281
1282        let midpoint = |idx: usize| -> usize { starts[idx].saturating_add(widths[idx] / 2) };
1283
1284        while position < run_end && col >= midpoint(reorder_indices[position + 1]) {
1285            position += 1;
1286        }
1287
1288        while position > run_start && col < midpoint(reorder_indices[position - 1]) {
1289            position -= 1;
1290        }
1291
1292        let target = reorder_indices[position];
1293        (target != current_index).then_some(target)
1294    }
1295
1296    pub(crate) fn viewport_layout(
1297        tabs: &[DraggableTab],
1298        opts: &TabDisplayOptions<'_>,
1299        vp: &TabViewportOptions,
1300    ) -> TabViewportLayout {
1301        let len = tabs.len();
1302        if len == 0 || vp.viewport_width == 0 {
1303            return TabViewportLayout {
1304                offset: 0,
1305                visible_tabs: Vec::new(),
1306                hidden_left: 0,
1307                hidden_right: 0,
1308                content_start: 0,
1309                content_width: 0,
1310                left_control: None,
1311                right_control: None,
1312            };
1313        }
1314
1315        let (runs, total_width) = tab_runs_for_viewport(tabs, opts, Some(vp.viewport_width));
1316
1317        let requested_offset = vp.scroll_offset;
1318        let mut offset = vp.scroll_offset;
1319        let mut align_right = false;
1320        let mut left_width = 0usize;
1321        let mut right_width = 0usize;
1322        let mut visible_tabs = Vec::new();
1323        let mut hidden_left = 0usize;
1324        let mut hidden_right = 0usize;
1325
1326        for _ in 0..8 {
1327            visible_tabs.clear();
1328            let mut available = vp.viewport_width.saturating_sub(left_width + right_width);
1329            if available == 0 {
1330                if right_width > 0 {
1331                    right_width = 0;
1332                    available = vp.viewport_width.saturating_sub(left_width);
1333                } else if left_width > 0 {
1334                    left_width = 0;
1335                    available = vp.viewport_width;
1336                }
1337            }
1338
1339            let max_scroll = total_width.saturating_sub(available);
1340            if align_right || requested_offset >= max_scroll {
1341                align_right = true;
1342                offset = max_scroll;
1343            } else {
1344                offset = requested_offset.min(max_scroll);
1345            }
1346            let view_start = offset;
1347            let view_end = view_start.saturating_add(available);
1348
1349            hidden_left = 0;
1350            hidden_right = 0;
1351            for (idx, (start, end, metrics)) in runs.iter().enumerate() {
1352                if *end <= view_start {
1353                    hidden_left = idx.saturating_add(1);
1354                    continue;
1355                }
1356                if *start >= view_end {
1357                    hidden_right = len.saturating_sub(idx);
1358                    break;
1359                }
1360
1361                let visible_start = (*start).max(view_start);
1362                let visible_end = (*end).min(view_end);
1363                if visible_start >= visible_end {
1364                    continue;
1365                }
1366
1367                if visible_start > *start {
1368                    hidden_left = idx.saturating_add(1);
1369                }
1370
1371                visible_tabs.push(VisibleTab {
1372                    index: idx,
1373                    start: visible_start.saturating_sub(view_start),
1374                    end: visible_end.saturating_sub(view_start),
1375                    metrics: *metrics,
1376                    clip_left: visible_start.saturating_sub(*start),
1377                });
1378
1379                if visible_end < *end {
1380                    hidden_right = len.saturating_sub(idx);
1381                    break;
1382                }
1383            }
1384
1385            if visible_tabs.is_empty() && offset > 0 {
1386                offset = offset.saturating_sub(1);
1387                continue;
1388            }
1389
1390            if hidden_right == 0 {
1391                hidden_right = if let Some(last) = visible_tabs.last() {
1392                    len.saturating_sub(last.index + 1)
1393                } else {
1394                    len.saturating_sub(offset)
1395                };
1396            }
1397
1398            let next_left = if vp.show_overflow_controls && hidden_left > 0 {
1399                vp.overflow_labels
1400                    .width(OverflowControlSide::Left, hidden_left)
1401            } else {
1402                0
1403            };
1404            let next_right = if vp.show_overflow_controls && hidden_right > 0 {
1405                vp.overflow_labels
1406                    .width(OverflowControlSide::Right, hidden_right)
1407            } else {
1408                0
1409            };
1410
1411            if next_left == left_width && next_right == right_width {
1412                break;
1413            }
1414
1415            left_width = next_left;
1416            right_width = next_right;
1417        }
1418
1419        let content_start = left_width.min(vp.viewport_width);
1420        let content_width = vp.viewport_width.saturating_sub(left_width + right_width);
1421
1422        let mut shifted_tabs = Vec::with_capacity(visible_tabs.len());
1423        for tab in visible_tabs {
1424            shifted_tabs.push(VisibleTab {
1425                start: tab.start.saturating_add(content_start),
1426                end: tab.end.saturating_add(content_start),
1427                ..tab
1428            });
1429        }
1430
1431        let left_control = if left_width > 0 {
1432            Some(OverflowControl {
1433                start: 0,
1434                end: left_width,
1435                label: vp
1436                    .overflow_labels
1437                    .label(OverflowControlSide::Left, hidden_left),
1438            })
1439        } else {
1440            None
1441        };
1442
1443        let right_control = if right_width > 0 {
1444            let start = vp.viewport_width.saturating_sub(right_width);
1445            Some(OverflowControl {
1446                start,
1447                end: vp.viewport_width,
1448                label: vp
1449                    .overflow_labels
1450                    .label(OverflowControlSide::Right, hidden_right),
1451            })
1452        } else {
1453            None
1454        };
1455
1456        TabViewportLayout {
1457            offset,
1458            visible_tabs: shifted_tabs,
1459            hidden_left,
1460            hidden_right,
1461            content_start,
1462            content_width,
1463            left_control,
1464            right_control,
1465        }
1466    }
1467
1468    pub(crate) fn hit_target_at_view_col(
1469        tabs: &[DraggableTab],
1470        opts: &TabDisplayOptions<'_>,
1471        vp: &TabViewportOptions,
1472        col: usize,
1473    ) -> Option<DraggableTabHitTarget> {
1474        let layout = Self::viewport_layout(tabs, opts, vp);
1475
1476        if let Some(left) = &layout.left_control
1477            && col >= left.start
1478            && col < left.end
1479        {
1480            return Some(DraggableTabHitTarget::Overflow(OverflowControlSide::Left));
1481        }
1482        if let Some(right) = &layout.right_control
1483            && col >= right.start
1484            && col < right.end
1485        {
1486            return Some(DraggableTabHitTarget::Overflow(OverflowControlSide::Right));
1487        }
1488
1489        for tab in &layout.visible_tabs {
1490            if col < tab.start || col >= tab.end {
1491                continue;
1492            }
1493            let local = col.saturating_sub(tab.start).saturating_add(tab.clip_left);
1494            let part = if let (Some(close_start), Some(close_end)) =
1495                (tab.metrics.close_start, tab.metrics.close_end)
1496            {
1497                if local >= close_start && local < close_end {
1498                    DraggableTabHitPart::Close
1499                } else {
1500                    DraggableTabHitPart::Body
1501                }
1502            } else {
1503                DraggableTabHitPart::Body
1504            };
1505            return Some(DraggableTabHitTarget::Tab(DraggableTabHit {
1506                index: tab.index,
1507                part,
1508            }));
1509        }
1510
1511        None
1512    }
1513
1514    pub(crate) fn global_col_from_view_col(
1515        tabs: &[DraggableTab],
1516        opts: &TabDisplayOptions<'_>,
1517        vp: &TabViewportOptions,
1518        col: usize,
1519    ) -> Option<usize> {
1520        let layout = Self::viewport_layout(tabs, opts, vp);
1521
1522        if col < layout.content_start
1523            || col >= layout.content_start.saturating_add(layout.content_width)
1524        {
1525            return None;
1526        }
1527
1528        let view_col = col.saturating_sub(layout.content_start);
1529        Some(layout.offset.saturating_add(view_col))
1530    }
1531
1532    pub(crate) fn reorder_index_at_view_col(
1533        tabs: &[DraggableTab],
1534        opts: &TabDisplayOptions<'_>,
1535        vp: &TabViewportOptions,
1536        col: usize,
1537    ) -> Option<usize> {
1538        let global_col = Self::global_col_from_view_col(tabs, opts, vp, col)?;
1539        let metrics = tab_metrics_for_viewport(tabs, opts, Some(vp.viewport_width));
1540        Self::reorder_index_at_col_with_metrics(tabs, opts, &metrics, global_col)
1541    }
1542
1543    pub(crate) fn adjacent_reorder_target_at_view_col(
1544        tabs: &[DraggableTab],
1545        opts: &TabDisplayOptions<'_>,
1546        vp: &TabViewportOptions,
1547        current_index: usize,
1548        col: usize,
1549    ) -> Option<usize> {
1550        let global_col = Self::global_col_from_view_col(tabs, opts, vp, col)?;
1551        let metrics = tab_metrics_for_viewport(tabs, opts, Some(vp.viewport_width));
1552        Self::adjacent_reorder_target_with_metrics(tabs, opts, &metrics, current_index, global_col)
1553    }
1554
1555    pub(crate) fn scroll_offset_for_step(
1556        tabs: &[DraggableTab],
1557        opts: &TabDisplayOptions<'_>,
1558        vp: &TabViewportOptions,
1559        step_right: bool,
1560        step_chars: usize,
1561    ) -> usize {
1562        if tabs.is_empty() || vp.viewport_width == 0 {
1563            return 0;
1564        }
1565
1566        let layout = Self::viewport_layout(tabs, opts, vp);
1567        let current = layout.offset;
1568
1569        if (step_right && layout.hidden_right == 0) || (!step_right && layout.hidden_left == 0) {
1570            return current;
1571        }
1572
1573        let step = step_chars.max(1);
1574        let requested = if step_right {
1575            current.saturating_add(step)
1576        } else {
1577            current.saturating_sub(step)
1578        };
1579
1580        let canonical = Self::viewport_layout(
1581            tabs,
1582            opts,
1583            &TabViewportOptions {
1584                scroll_offset: requested,
1585                ..vp.clone()
1586            },
1587        )
1588        .offset;
1589
1590        if canonical != current {
1591            return canonical;
1592        }
1593
1594        if step_right {
1595            let total_width = Self::content_width_for_viewport(tabs, opts, vp.viewport_width);
1596            return Self::viewport_layout(
1597                tabs,
1598                opts,
1599                &TabViewportOptions {
1600                    scroll_offset: total_width.saturating_sub(1),
1601                    ..vp.clone()
1602                },
1603            )
1604            .offset;
1605        }
1606
1607        0
1608    }
1609
1610    pub(crate) fn scroll_offset_to_reveal_tab(
1611        tabs: &[DraggableTab],
1612        opts: &TabDisplayOptions<'_>,
1613        vp: &TabViewportOptions,
1614        tab_index: usize,
1615    ) -> usize {
1616        if tabs.is_empty() || vp.viewport_width == 0 {
1617            return 0;
1618        }
1619        let target = tab_index.min(tabs.len().saturating_sub(1));
1620        let metrics = tab_metrics_for_viewport(tabs, opts, Some(vp.viewport_width));
1621        let target_start = tabs_prefix_width_from_metrics(&metrics, opts, target);
1622        let target_width = metrics[target].width;
1623        let target_end = target_start.saturating_add(target_width);
1624
1625        let mut offset = vp.scroll_offset;
1626        for _ in 0..8 {
1627            let layout = Self::viewport_layout(
1628                tabs,
1629                opts,
1630                &TabViewportOptions {
1631                    scroll_offset: offset,
1632                    ..vp.clone()
1633                },
1634            );
1635            let view_start = layout.offset;
1636            let view_end = view_start.saturating_add(layout.content_width);
1637
1638            let next = if target_width > layout.content_width || target_start < view_start {
1639                target_start
1640            } else if target_end > view_end {
1641                target_end.saturating_sub(layout.content_width)
1642            } else {
1643                return view_start;
1644            };
1645
1646            if next == offset {
1647                return layout.offset;
1648            }
1649            offset = next;
1650        }
1651
1652        offset
1653    }
1654}
1655
1656pub(crate) fn is_reorderable_tab(tab: &DraggableTab) -> bool {
1657    tab.kind == DraggableTabKind::Tab
1658}
1659
1660#[cfg(test)]
1661pub(crate) fn reorder_target_at_col_with_options(
1662    tabs: &[DraggableTab],
1663    opts: &TabDisplayOptions<'_>,
1664    col: usize,
1665) -> Option<usize> {
1666    let candidate = DraggableTabBar::reorder_index_at_col_with_options(tabs, opts, col)?;
1667    if tabs.get(candidate).is_some_and(is_reorderable_tab) {
1668        return Some(candidate);
1669    }
1670
1671    (0..candidate)
1672        .rev()
1673        .find(|&index| tabs.get(index).is_some_and(is_reorderable_tab))
1674        .or_else(|| {
1675            ((candidate + 1)..tabs.len())
1676                .find(|&index| tabs.get(index).is_some_and(is_reorderable_tab))
1677        })
1678}
1679
1680pub(crate) fn reorder_target_at_view_col_with_options(
1681    tabs: &[DraggableTab],
1682    opts: &TabDisplayOptions<'_>,
1683    vp: &TabViewportOptions,
1684    col: usize,
1685) -> Option<usize> {
1686    let candidate = DraggableTabBar::reorder_index_at_view_col(tabs, opts, vp, col)?;
1687    if tabs.get(candidate).is_some_and(is_reorderable_tab) {
1688        return Some(candidate);
1689    }
1690
1691    (0..candidate)
1692        .rev()
1693        .find(|&index| tabs.get(index).is_some_and(is_reorderable_tab))
1694        .or_else(|| {
1695            ((candidate + 1)..tabs.len())
1696                .find(|&index| tabs.get(index).is_some_and(is_reorderable_tab))
1697        })
1698}
1699
1700fn separator_width(variant: DraggableTabBarVariant, divider: char) -> usize {
1701    match variant {
1702        DraggableTabBarVariant::Bordered => UnicodeWidthChar::width(divider).unwrap_or(1),
1703        DraggableTabBarVariant::FrameLine => 0,
1704    }
1705}
1706
1707#[cfg(test)]
1708pub(crate) fn tab_metrics(
1709    tab: &DraggableTab,
1710    variant: DraggableTabBarVariant,
1711    accent_symbol: char,
1712    close_symbol: &str,
1713    show_close_buttons: bool,
1714) -> TabMetrics {
1715    tab_metrics_with_options(
1716        tab,
1717        &TabDisplayOptions {
1718            variant,
1719            divider: '|',
1720            accent_symbol,
1721            close_symbol,
1722            show_close_buttons,
1723            tab_max_width: None,
1724            overflow: DraggableTabBarOverflow::Scroll,
1725            show_file_icons: false,
1726            file_icon_style: FileIconStyle::NerdFont,
1727            file_icon_palette: &FileIconPalette::default(),
1728            file_icon_overrides: &HashMap::new(),
1729            width_lock: None,
1730        },
1731    )
1732}
1733
1734pub(crate) fn tab_metrics_with_options(
1735    tab: &DraggableTab,
1736    opts: &TabDisplayOptions<'_>,
1737) -> TabMetrics {
1738    let label_w = tab_label_width(tab, opts);
1739    tab_metrics_with_label_width(tab, opts, label_w)
1740}
1741
1742fn tab_label_width(tab: &DraggableTab, opts: &TabDisplayOptions<'_>) -> usize {
1743    let mut label_w = UnicodeWidthStr::width(tab.label.as_ref());
1744    if let Some(max) = opts.tab_max_width {
1745        let max = (max as usize).max(1);
1746        label_w = label_w.min(max);
1747    }
1748    label_w
1749}
1750
1751fn tab_metrics_with_label_width(
1752    tab: &DraggableTab,
1753    opts: &TabDisplayOptions<'_>,
1754    label_w: usize,
1755) -> TabMetrics {
1756    let icon_w = resolve_tab_icon(
1757        tab,
1758        opts.show_file_icons,
1759        opts.file_icon_style,
1760        opts.file_icon_palette,
1761        opts.file_icon_overrides,
1762    )
1763    .map(|icon| UnicodeWidthStr::width(icon.content.as_ref()).saturating_add(1))
1764    .unwrap_or(0);
1765
1766    let badge_w = tab
1767        .right_badge
1768        .as_ref()
1769        .map(|badge| UnicodeWidthStr::width(badge.content.as_ref()))
1770        .unwrap_or(0);
1771    let badge_gap_w = if badge_w > 0 { 1 } else { 0 };
1772
1773    let close_symbol_w = UnicodeWidthStr::width(opts.close_symbol).max(1);
1774    let has_close = opts.show_close_buttons && tab.closeable && is_reorderable_tab(tab);
1775    let close_gap_w = if has_close { 1 } else { 0 };
1776    let close_zone_w = if has_close { close_symbol_w } else { 0 };
1777
1778    match opts.variant {
1779        DraggableTabBarVariant::Bordered => {
1780            let close_start =
1781                has_close.then_some(1 + icon_w + label_w + badge_gap_w + badge_w + close_gap_w);
1782            let close_end = has_close.then_some(
1783                1 + icon_w + label_w + badge_gap_w + badge_w + close_gap_w + close_zone_w,
1784            );
1785            TabMetrics {
1786                width: 1
1787                    + icon_w
1788                    + label_w
1789                    + badge_gap_w
1790                    + badge_w
1791                    + close_gap_w
1792                    + close_zone_w
1793                    + 1,
1794                close_start,
1795                close_end,
1796                label_width: label_w,
1797            }
1798        }
1799        DraggableTabBarVariant::FrameLine => {
1800            let accent_w = UnicodeWidthChar::width(opts.accent_symbol).unwrap_or(1);
1801            let close_start = has_close
1802                .then_some(accent_w + 1 + icon_w + label_w + badge_gap_w + badge_w + close_gap_w);
1803            let close_end = has_close.then_some(
1804                accent_w
1805                    + 1
1806                    + icon_w
1807                    + label_w
1808                    + badge_gap_w
1809                    + badge_w
1810                    + close_gap_w
1811                    + close_zone_w,
1812            );
1813            TabMetrics {
1814                width: accent_w
1815                    + 1
1816                    + icon_w
1817                    + label_w
1818                    + badge_gap_w
1819                    + badge_w
1820                    + close_gap_w
1821                    + close_zone_w
1822                    + 1,
1823                close_start,
1824                close_end,
1825                label_width: label_w,
1826            }
1827        }
1828    }
1829}
1830
1831fn natural_tab_metrics(tabs: &[DraggableTab], opts: &TabDisplayOptions<'_>) -> Vec<TabMetrics> {
1832    tabs.iter()
1833        .map(|tab| tab_metrics_with_options(tab, opts))
1834        .collect()
1835}
1836
1837fn tab_metrics_for_viewport(
1838    tabs: &[DraggableTab],
1839    opts: &TabDisplayOptions<'_>,
1840    viewport_width: Option<usize>,
1841) -> Vec<TabMetrics> {
1842    let natural = natural_tab_metrics(tabs, opts);
1843    let Some(viewport_width) = viewport_width else {
1844        return apply_tab_width_lock(tabs, opts, natural);
1845    };
1846
1847    let DraggableTabBarOverflow::ShrinkThenScroll { min_tab_width } = opts.overflow else {
1848        return apply_tab_width_lock(tabs, opts, natural);
1849    };
1850
1851    if tabs.is_empty() || viewport_width == 0 {
1852        return apply_tab_width_lock(tabs, opts, natural);
1853    }
1854
1855    let natural_total = total_width_for_metrics(&natural, opts);
1856    if natural_total <= viewport_width {
1857        return apply_tab_width_lock(tabs, opts, natural);
1858    }
1859
1860    let min_tab_width = (min_tab_width as usize).max(1);
1861    let min_label_widths = natural
1862        .iter()
1863        .map(|metrics| {
1864            let fixed_width = metrics.width.saturating_sub(metrics.label_width);
1865            metrics
1866                .label_width
1867                .min(min_tab_width.saturating_sub(fixed_width))
1868        })
1869        .collect::<Vec<_>>();
1870    let fixed_total = natural
1871        .iter()
1872        .map(|metrics| metrics.width.saturating_sub(metrics.label_width))
1873        .sum::<usize>()
1874        .saturating_add(separator_width(opts.variant, opts.divider) * tabs.len().saturating_sub(1));
1875    let min_total = fixed_total.saturating_add(min_label_widths.iter().sum::<usize>());
1876
1877    if min_total >= natural_total {
1878        return apply_tab_width_lock(tabs, opts, natural);
1879    }
1880
1881    if min_total > viewport_width {
1882        let metrics = tabs
1883            .iter()
1884            .zip(min_label_widths)
1885            .map(|(tab, label_width)| tab_metrics_with_label_width(tab, opts, label_width))
1886            .collect();
1887        return apply_tab_width_lock(tabs, opts, metrics);
1888    }
1889
1890    let max_label_width = natural
1891        .iter()
1892        .map(|metrics| metrics.label_width)
1893        .max()
1894        .unwrap_or(0);
1895    let mut low = 0usize;
1896    let mut high = max_label_width;
1897    while low < high {
1898        let mid = (low + high).div_ceil(2);
1899        let total = total_width_for_label_cap(&natural, &min_label_widths, fixed_total, mid);
1900        if total <= viewport_width {
1901            low = mid;
1902        } else {
1903            high = mid.saturating_sub(1);
1904        }
1905    }
1906
1907    let cap = low;
1908    let mut label_widths = natural
1909        .iter()
1910        .zip(&min_label_widths)
1911        .map(|(metrics, &min_label_width)| metrics.label_width.min(cap.max(min_label_width)))
1912        .collect::<Vec<_>>();
1913    let total = fixed_total.saturating_add(label_widths.iter().sum::<usize>());
1914    let mut spare = viewport_width.saturating_sub(total);
1915    if spare > 0 {
1916        for (label_width, natural_metrics) in label_widths.iter_mut().zip(&natural) {
1917            if spare == 0 {
1918                break;
1919            }
1920            if *label_width < natural_metrics.label_width {
1921                *label_width += 1;
1922                spare -= 1;
1923            }
1924        }
1925    }
1926
1927    let metrics = tabs
1928        .iter()
1929        .zip(label_widths)
1930        .map(|(tab, label_width)| tab_metrics_with_label_width(tab, opts, label_width))
1931        .collect();
1932    apply_tab_width_lock(tabs, opts, metrics)
1933}
1934
1935fn apply_tab_width_lock(
1936    tabs: &[DraggableTab],
1937    opts: &TabDisplayOptions<'_>,
1938    mut metrics: Vec<TabMetrics>,
1939) -> Vec<TabMetrics> {
1940    let Some(lock) = opts.width_lock else {
1941        return metrics;
1942    };
1943    let (Some(tab), Some(current)) = (tabs.get(lock.index), metrics.get(lock.index)) else {
1944        return metrics;
1945    };
1946    if !is_reorderable_tab(tab) {
1947        return metrics;
1948    }
1949    let fixed_width = current.width.saturating_sub(current.label_width);
1950    let label_width = lock.width.saturating_sub(fixed_width);
1951    metrics[lock.index] = tab_metrics_with_label_width(tab, opts, label_width);
1952    metrics
1953}
1954
1955fn total_width_for_label_cap(
1956    natural: &[TabMetrics],
1957    min_label_widths: &[usize],
1958    fixed_total: usize,
1959    cap: usize,
1960) -> usize {
1961    fixed_total.saturating_add(
1962        natural
1963            .iter()
1964            .zip(min_label_widths)
1965            .map(|(metrics, &min_label_width)| metrics.label_width.min(cap.max(min_label_width)))
1966            .sum::<usize>(),
1967    )
1968}
1969
1970fn total_width_for_metrics(metrics: &[TabMetrics], opts: &TabDisplayOptions<'_>) -> usize {
1971    let tabs_width = metrics.iter().map(|metrics| metrics.width).sum::<usize>();
1972    tabs_width.saturating_add(
1973        separator_width(opts.variant, opts.divider) * metrics.len().saturating_sub(1),
1974    )
1975}
1976
1977fn tab_runs_for_metrics(
1978    metrics: &[TabMetrics],
1979    opts: &TabDisplayOptions<'_>,
1980) -> (Vec<(usize, usize, TabMetrics)>, usize) {
1981    let mut runs = Vec::with_capacity(metrics.len());
1982    let mut total_width = 0usize;
1983    for (i, tab_metrics) in metrics.iter().copied().enumerate() {
1984        let start = total_width;
1985        let end = start.saturating_add(tab_metrics.width);
1986        runs.push((start, end, tab_metrics));
1987        total_width = end;
1988        if i + 1 < metrics.len() {
1989            total_width = total_width.saturating_add(separator_width(opts.variant, opts.divider));
1990        }
1991    }
1992    (runs, total_width)
1993}
1994
1995fn tab_runs_for_viewport(
1996    tabs: &[DraggableTab],
1997    opts: &TabDisplayOptions<'_>,
1998    viewport_width: Option<usize>,
1999) -> (Vec<(usize, usize, TabMetrics)>, usize) {
2000    let metrics = tab_metrics_for_viewport(tabs, opts, viewport_width);
2001    tab_runs_for_metrics(&metrics, opts)
2002}
2003
2004fn tabs_prefix_width_from_metrics(
2005    metrics: &[TabMetrics],
2006    opts: &TabDisplayOptions<'_>,
2007    offset: usize,
2008) -> usize {
2009    let upto = offset.min(metrics.len());
2010    let tabs_width = metrics
2011        .iter()
2012        .take(upto)
2013        .map(|metrics| metrics.width)
2014        .sum::<usize>();
2015    let separators = upto.min(metrics.len().saturating_sub(1));
2016    tabs_width.saturating_add(separator_width(opts.variant, opts.divider) * separators)
2017}
2018
2019#[cfg(test)]
2020fn tabs_prefix_width(tabs: &[DraggableTab], opts: &TabDisplayOptions<'_>, offset: usize) -> usize {
2021    let metrics = tab_metrics_for_viewport(tabs, opts, None);
2022    tabs_prefix_width_from_metrics(&metrics, opts, offset)
2023}
2024
2025pub(crate) fn tab_fully_visible_at_offset(
2026    tabs: &[DraggableTab],
2027    opts: &TabDisplayOptions<'_>,
2028    tab_index: usize,
2029    offset: usize,
2030    viewport_width: usize,
2031) -> bool {
2032    if tabs.is_empty() || tab_index >= tabs.len() || viewport_width == 0 {
2033        return false;
2034    }
2035    let metrics = tab_metrics_for_viewport(tabs, opts, Some(viewport_width));
2036    let tab_start = tabs_prefix_width_from_metrics(&metrics, opts, tab_index);
2037    let tab_width = metrics[tab_index].width;
2038    let tab_end = tab_start.saturating_add(tab_width);
2039    tab_start >= offset && tab_end <= offset.saturating_add(viewport_width)
2040}
2041
2042fn default_overflow_control_label(side: OverflowControlSide, hidden_count: usize) -> Arc<str> {
2043    match side {
2044        OverflowControlSide::Left => Arc::from(format!(" {} ", hidden_count)),
2045        OverflowControlSide::Right => Arc::from(format!("  {}", hidden_count)),
2046    }
2047}
2048
2049fn lookup_icon_override<'a>(
2050    key: &str,
2051    overrides: &'a HashMap<Arc<str>, FileIconOverride>,
2052) -> Option<&'a FileIconOverride> {
2053    let path = Path::new(key);
2054    if let Some(name) = path.file_name().and_then(|n| n.to_str())
2055        && let Some(override_icon) = overrides.get(name)
2056    {
2057        return Some(override_icon);
2058    }
2059    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2060        return overrides.get(ext);
2061    }
2062    None
2063}
2064
2065pub(crate) fn resolve_tab_icon(
2066    tab: &DraggableTab,
2067    show_file_icons: bool,
2068    file_icon_style: FileIconStyle,
2069    file_icon_palette: &FileIconPalette,
2070    file_icon_overrides: &HashMap<Arc<str>, FileIconOverride>,
2071) -> Option<Span> {
2072    if let Some(leading) = &tab.leading {
2073        return Some(leading.to_span());
2074    }
2075
2076    if let Some(icon) = &tab.icon {
2077        return Some(icon.clone());
2078    }
2079
2080    if !show_file_icons {
2081        return None;
2082    }
2083
2084    let key = tab.path.as_deref().unwrap_or(tab.label.as_ref());
2085    if let Some(override_icon) = lookup_icon_override(key, file_icon_overrides) {
2086        let mut span = Span::new(override_icon.icon.clone());
2087        if let Some(color) = override_icon.color {
2088            span = span.fg(color);
2089        }
2090        return Some(span);
2091    }
2092
2093    match file_icon_style {
2094        FileIconStyle::Text => Some(Span::new("[F]")),
2095        FileIconStyle::NerdFont | FileIconStyle::NerdFontColored => {
2096            let (icon, color) = file_icon(key, file_icon_palette);
2097            let mut span = Span::new(icon);
2098            if matches!(file_icon_style, FileIconStyle::NerdFontColored)
2099                && let Some(color) = color
2100            {
2101                span = span.fg(color);
2102            }
2103            Some(span)
2104        }
2105    }
2106}
2107
2108impl From<DraggableTabBar> for Element {
2109    fn from(value: DraggableTabBar) -> Self {
2110        Element::new(ElementKind::DraggableTabBar(Box::new(value)))
2111    }
2112}
2113
2114impl crate::layout::hash::LayoutHash for DraggableTabBar {
2115    fn layout_hash(
2116        &self,
2117        hasher: &mut impl std::hash::Hasher,
2118        _recurse: &dyn Fn(&crate::core::element::Element) -> Option<u64>,
2119    ) -> Option<()> {
2120        use std::hash::Hash;
2121        self.width.hash(hasher);
2122        self.height.hash(hasher);
2123        self.border.hash(hasher);
2124        self.border_style.hash(hasher);
2125        self.padding.hash(hasher);
2126        self.tabs.len().hash(hasher);
2127        self.active.hash(hasher);
2128        self.divider.hash(hasher);
2129        self.close_symbol.hash(hasher);
2130        self.accent_symbol.hash(hasher);
2131        self.active_accent_symbol.hash(hasher);
2132        self.close_on_hover_only.hash(hasher);
2133        self.tab_max_width.hash(hasher);
2134        self.overflow.hash(hasher);
2135        self.show_overflow_controls.hash(hasher);
2136        self.overflow_labels.left.is_some().hash(hasher);
2137        self.overflow_labels.right.is_some().hash(hasher);
2138        self.empty_text.hash(hasher);
2139        self.scroll_offset.hash(hasher);
2140        self.show_file_icons.hash(hasher);
2141        self.file_icon_style.hash(hasher);
2142        self.variant.hash(hasher);
2143        self.show_close_buttons.hash(hasher);
2144        self.draggable.hash(hasher);
2145        self.drag_preview.hash(hasher);
2146        self.reorder_mode.hash(hasher);
2147        self.drag_threshold.hash(hasher);
2148        Some(())
2149    }
2150}
2151
2152#[cfg(test)]
2153mod tests {
2154    use std::collections::HashMap;
2155
2156    use crate::style::{FileIconPalette, Span, Style};
2157
2158    use super::{
2159        DraggableTab, DraggableTabBar, DraggableTabBarOverflow, DraggableTabBarVariant,
2160        DraggableTabHitPart,
2161    };
2162    use crate::widgets::FileIconStyle;
2163
2164    #[test]
2165    fn hit_at_col_detects_close_region() {
2166        let tabs = vec![DraggableTab::new("main.rs").closeable(true)];
2167        let label_w = "main.rs".chars().count();
2168        let hit = DraggableTabBar::hit_at_col(
2169            &tabs,
2170            DraggableTabBarVariant::Bordered,
2171            '|',
2172            '|',
2173            "x",
2174            true,
2175            1 + label_w + 1,
2176        )
2177        .expect("expected hit");
2178        assert_eq!(hit.index, 0);
2179        assert_eq!(hit.part, DraggableTabHitPart::Close);
2180    }
2181
2182    #[test]
2183    fn hit_at_col_detects_close_region_with_badge() {
2184        let tab = DraggableTab::new("main.rs")
2185            .right_badge(Span::new("M"))
2186            .closeable(true);
2187        let tabs = vec![tab];
2188        let metrics =
2189            super::tab_metrics(&tabs[0], DraggableTabBarVariant::Bordered, '|', "x", true);
2190        let close_col = metrics.close_start.expect("close start");
2191        let hit = DraggableTabBar::hit_at_col(
2192            &tabs,
2193            DraggableTabBarVariant::Bordered,
2194            '|',
2195            '|',
2196            "x",
2197            true,
2198            close_col,
2199        )
2200        .expect("expected hit");
2201        assert_eq!(hit.index, 0);
2202        assert_eq!(hit.part, DraggableTabHitPart::Close);
2203    }
2204
2205    #[test]
2206    fn action_tab_does_not_reserve_close_region() {
2207        let tab = DraggableTab::action("+").closeable(true);
2208        let metrics = super::tab_metrics(&tab, DraggableTabBarVariant::Bordered, '|', "x", true);
2209
2210        assert_eq!(metrics.close_start, None);
2211        assert_eq!(metrics.close_end, None);
2212        assert_eq!(metrics.width, 3);
2213    }
2214
2215    #[test]
2216    fn hit_at_col_returns_none_on_separator() {
2217        let tabs = vec![DraggableTab::new("a"), DraggableTab::new("b")];
2218        let separator_col = 3; // first tab " a " is width 3
2219        let hit = DraggableTabBar::hit_at_col(
2220            &tabs,
2221            DraggableTabBarVariant::Bordered,
2222            '|',
2223            '|',
2224            "x",
2225            true,
2226            separator_col,
2227        );
2228        assert!(hit.is_none());
2229    }
2230
2231    #[test]
2232    fn frame_line_content_width_is_nonzero() {
2233        let tabs = vec![DraggableTab::new("file.rs").closeable(true)];
2234        let width = DraggableTabBar::content_width(
2235            &tabs,
2236            DraggableTabBarVariant::FrameLine,
2237            '|',
2238            '|',
2239            "x",
2240            true,
2241        );
2242        assert!(width > 0);
2243    }
2244
2245    #[test]
2246    fn reorder_index_maps_separator_to_adjacent_tab() {
2247        let tabs = vec![DraggableTab::new("a"), DraggableTab::new("b")];
2248        let separator_col = 3; // first tab " a " is width 3
2249        let idx = DraggableTabBar::reorder_index_at_col(
2250            &tabs,
2251            DraggableTabBarVariant::Bordered,
2252            '|',
2253            '|',
2254            "x",
2255            true,
2256            separator_col,
2257        )
2258        .expect("expected mapped index");
2259        assert_eq!(idx, 1);
2260    }
2261
2262    #[test]
2263    fn adjacent_reorder_waits_until_midpoint_for_wider_neighbor() {
2264        let tabs = vec![DraggableTab::new("a"), DraggableTab::new("very-long-name")];
2265        let at_divider = 3; // after " a " in bordered variant
2266
2267        let opts = super::TabDisplayOptions {
2268            variant: DraggableTabBarVariant::Bordered,
2269            divider: '|',
2270            accent_symbol: '|',
2271            close_symbol: "x",
2272            show_close_buttons: false,
2273            tab_max_width: None,
2274            overflow: super::DraggableTabBarOverflow::Scroll,
2275            show_file_icons: false,
2276            file_icon_style: FileIconStyle::NerdFont,
2277            file_icon_palette: &FileIconPalette::default(),
2278            file_icon_overrides: &HashMap::new(),
2279            width_lock: None,
2280        };
2281        let target = DraggableTabBar::adjacent_reorder_target(&tabs, &opts, 0, at_divider);
2282        assert!(target.is_none());
2283
2284        let second_start =
2285            super::tab_metrics(&tabs[0], DraggableTabBarVariant::Bordered, '|', "x", false).width
2286                + super::separator_width(DraggableTabBarVariant::Bordered, '|');
2287        let second_mid = second_start
2288            + super::tab_metrics(&tabs[1], DraggableTabBarVariant::Bordered, '|', "x", false).width
2289                / 2;
2290        let target = DraggableTabBar::adjacent_reorder_target(&tabs, &opts, 0, second_mid);
2291        assert_eq!(target, Some(1));
2292    }
2293
2294    #[test]
2295    fn adjacent_reorder_does_not_target_trailing_action_tab() {
2296        let tabs = vec![
2297            DraggableTab::new("a"),
2298            DraggableTab::new("b"),
2299            DraggableTab::action("+"),
2300        ];
2301        let opts = super::TabDisplayOptions {
2302            variant: DraggableTabBarVariant::Bordered,
2303            divider: '|',
2304            accent_symbol: '|',
2305            close_symbol: "x",
2306            show_close_buttons: false,
2307            tab_max_width: None,
2308            overflow: super::DraggableTabBarOverflow::Scroll,
2309            show_file_icons: false,
2310            file_icon_style: FileIconStyle::NerdFont,
2311            file_icon_palette: &FileIconPalette::default(),
2312            file_icon_overrides: &HashMap::new(),
2313            width_lock: None,
2314        };
2315        let action_mid = super::tabs_prefix_width(&tabs, &opts, 2)
2316            + super::tab_metrics(&tabs[2], DraggableTabBarVariant::Bordered, '|', "x", false).width
2317                / 2;
2318
2319        let target = DraggableTabBar::adjacent_reorder_target(&tabs, &opts, 1, action_mid);
2320
2321        assert_eq!(target, None);
2322    }
2323
2324    #[test]
2325    fn tab_prefix_includes_the_separator_before_the_target() {
2326        let tabs = vec![DraggableTab::new("alpha"), DraggableTab::new("beta")];
2327        let overrides = HashMap::new();
2328        let opts = super::TabDisplayOptions {
2329            variant: DraggableTabBarVariant::Bordered,
2330            divider: '|',
2331            accent_symbol: '|',
2332            close_symbol: "x",
2333            show_close_buttons: false,
2334            tab_max_width: None,
2335            overflow: super::DraggableTabBarOverflow::Scroll,
2336            show_file_icons: false,
2337            file_icon_style: FileIconStyle::NerdFont,
2338            file_icon_palette: &FileIconPalette::default(),
2339            file_icon_overrides: &overrides,
2340            width_lock: None,
2341        };
2342        let first =
2343            super::tab_metrics(&tabs[0], DraggableTabBarVariant::Bordered, '|', "x", false).width;
2344
2345        assert_eq!(
2346            super::tabs_prefix_width(&tabs, &opts, 1),
2347            first + super::separator_width(DraggableTabBarVariant::Bordered, '|')
2348        );
2349    }
2350
2351    #[test]
2352    fn on_drop_reorder_maps_action_hit_to_previous_tab() {
2353        let tabs = vec![
2354            DraggableTab::new("a"),
2355            DraggableTab::new("b"),
2356            DraggableTab::action("+"),
2357        ];
2358        let opts = super::TabDisplayOptions {
2359            variant: DraggableTabBarVariant::Bordered,
2360            divider: '|',
2361            accent_symbol: '|',
2362            close_symbol: "x",
2363            show_close_buttons: false,
2364            tab_max_width: None,
2365            overflow: super::DraggableTabBarOverflow::Scroll,
2366            show_file_icons: false,
2367            file_icon_style: FileIconStyle::NerdFont,
2368            file_icon_palette: &FileIconPalette::default(),
2369            file_icon_overrides: &HashMap::new(),
2370            width_lock: None,
2371        };
2372        let action_col = super::tabs_prefix_width(&tabs, &opts, 2);
2373
2374        let raw_target =
2375            DraggableTabBar::reorder_index_at_col_with_options(&tabs, &opts, action_col);
2376        let reorder_target = super::reorder_target_at_col_with_options(&tabs, &opts, action_col);
2377
2378        assert_eq!(raw_target, Some(2));
2379        assert_eq!(reorder_target, Some(1));
2380    }
2381
2382    #[test]
2383    fn viewport_layout_keeps_partially_visible_tab() {
2384        let tabs = vec![
2385            DraggableTab::new("alpha"),
2386            DraggableTab::new("beta-gamma"),
2387            DraggableTab::new("delta"),
2388        ];
2389        let first =
2390            super::tab_metrics(&tabs[0], DraggableTabBarVariant::Bordered, '|', "x", false).width;
2391        let sep = super::separator_width(DraggableTabBarVariant::Bordered, '|');
2392        let second =
2393            super::tab_metrics(&tabs[1], DraggableTabBarVariant::Bordered, '|', "x", false).width;
2394        let viewport_width = first + sep + (second / 2).max(1);
2395
2396        let overrides: HashMap<std::sync::Arc<str>, super::FileIconOverride> = HashMap::new();
2397        let layout = DraggableTabBar::viewport_layout(
2398            &tabs,
2399            &super::TabDisplayOptions {
2400                variant: DraggableTabBarVariant::Bordered,
2401                divider: '|',
2402                accent_symbol: '|',
2403                close_symbol: "x",
2404                show_close_buttons: false,
2405                tab_max_width: None,
2406                overflow: super::DraggableTabBarOverflow::Scroll,
2407                show_file_icons: false,
2408                file_icon_style: crate::widgets::FileIconStyle::NerdFont,
2409                file_icon_palette: &FileIconPalette::default(),
2410                file_icon_overrides: &overrides,
2411                width_lock: None,
2412            },
2413            &super::TabViewportOptions {
2414                scroll_offset: 0,
2415                viewport_width,
2416                show_overflow_controls: true,
2417                overflow_labels: Default::default(),
2418            },
2419        );
2420
2421        assert_eq!(layout.visible_tabs.len(), 2);
2422        assert_eq!(layout.visible_tabs[0].index, 0);
2423        assert_eq!(layout.visible_tabs[1].index, 1);
2424        assert!(
2425            layout.visible_tabs[1]
2426                .end
2427                .saturating_sub(layout.visible_tabs[1].start)
2428                < second
2429        );
2430        assert_eq!(layout.hidden_right, 2);
2431    }
2432
2433    #[test]
2434    fn viewport_layout_can_clip_left_tab() {
2435        let tabs = vec![
2436            DraggableTab::new("alpha"),
2437            DraggableTab::new("beta"),
2438            DraggableTab::new("gamma"),
2439        ];
2440        let overrides: HashMap<std::sync::Arc<str>, super::FileIconOverride> = HashMap::new();
2441        let layout = DraggableTabBar::viewport_layout(
2442            &tabs,
2443            &super::TabDisplayOptions {
2444                variant: DraggableTabBarVariant::Bordered,
2445                divider: '|',
2446                accent_symbol: '|',
2447                close_symbol: "x",
2448                show_close_buttons: false,
2449                tab_max_width: None,
2450                overflow: super::DraggableTabBarOverflow::Scroll,
2451                show_file_icons: false,
2452                file_icon_style: crate::widgets::FileIconStyle::NerdFont,
2453                file_icon_palette: &FileIconPalette::default(),
2454                file_icon_overrides: &overrides,
2455                width_lock: None,
2456            },
2457            &super::TabViewportOptions {
2458                scroll_offset: 1,
2459                viewport_width: 10,
2460                show_overflow_controls: true,
2461                overflow_labels: Default::default(),
2462            },
2463        );
2464
2465        assert!(layout.hidden_left > 0);
2466        let first = layout.visible_tabs.first().expect("expected visible tabs");
2467        assert_eq!(first.index, 0);
2468        assert!(first.clip_left > 0);
2469    }
2470
2471    #[test]
2472    fn shrink_then_scroll_fits_tabs_before_scrolling() {
2473        let tabs = vec![
2474            DraggableTab::new("abcdef"),
2475            DraggableTab::new("abcdef"),
2476            DraggableTab::new("abcdef"),
2477        ];
2478        let overrides: HashMap<std::sync::Arc<str>, super::FileIconOverride> = HashMap::new();
2479        let opts = super::TabDisplayOptions {
2480            variant: DraggableTabBarVariant::Bordered,
2481            divider: '|',
2482            accent_symbol: '|',
2483            close_symbol: "x",
2484            show_close_buttons: false,
2485            tab_max_width: None,
2486            overflow: DraggableTabBarOverflow::ShrinkThenScroll { min_tab_width: 5 },
2487            show_file_icons: false,
2488            file_icon_style: crate::widgets::FileIconStyle::NerdFont,
2489            file_icon_palette: &FileIconPalette::default(),
2490            file_icon_overrides: &overrides,
2491            width_lock: None,
2492        };
2493
2494        let layout = DraggableTabBar::viewport_layout(
2495            &tabs,
2496            &opts,
2497            &super::TabViewportOptions {
2498                scroll_offset: 0,
2499                viewport_width: 20,
2500                show_overflow_controls: true,
2501                overflow_labels: Default::default(),
2502            },
2503        );
2504
2505        assert_eq!(layout.hidden_left, 0);
2506        assert_eq!(layout.hidden_right, 0);
2507        assert!(layout.left_control.is_none());
2508        assert!(layout.right_control.is_none());
2509        assert_eq!(layout.visible_tabs.len(), 3);
2510        assert_eq!(layout.visible_tabs[0].metrics.label_width, 4);
2511        assert_eq!(layout.visible_tabs.last().expect("last tab").end, 20);
2512    }
2513
2514    #[test]
2515    fn shrink_then_scroll_scrolls_after_min_widths() {
2516        let tabs = vec![
2517            DraggableTab::new("abcdef"),
2518            DraggableTab::new("abcdef"),
2519            DraggableTab::new("abcdef"),
2520        ];
2521        let overrides: HashMap<std::sync::Arc<str>, super::FileIconOverride> = HashMap::new();
2522        let opts = super::TabDisplayOptions {
2523            variant: DraggableTabBarVariant::Bordered,
2524            divider: '|',
2525            accent_symbol: '|',
2526            close_symbol: "x",
2527            show_close_buttons: false,
2528            tab_max_width: None,
2529            overflow: DraggableTabBarOverflow::ShrinkThenScroll { min_tab_width: 5 },
2530            show_file_icons: false,
2531            file_icon_style: crate::widgets::FileIconStyle::NerdFont,
2532            file_icon_palette: &FileIconPalette::default(),
2533            file_icon_overrides: &overrides,
2534            width_lock: None,
2535        };
2536
2537        let layout = DraggableTabBar::viewport_layout(
2538            &tabs,
2539            &opts,
2540            &super::TabViewportOptions {
2541                scroll_offset: 0,
2542                viewport_width: 14,
2543                show_overflow_controls: true,
2544                overflow_labels: Default::default(),
2545            },
2546        );
2547
2548        assert_eq!(layout.visible_tabs[0].metrics.width, 5);
2549        assert_eq!(layout.visible_tabs[0].metrics.label_width, 3);
2550        assert_eq!(layout.hidden_left, 0);
2551        assert!(layout.hidden_right > 0);
2552        assert!(layout.right_control.is_some());
2553    }
2554
2555    #[test]
2556    fn shrink_then_scroll_hit_testing_uses_shrunken_widths() {
2557        let tabs = vec![
2558            DraggableTab::new("abcdef"),
2559            DraggableTab::new("abcdef"),
2560            DraggableTab::new("abcdef"),
2561        ];
2562        let overrides: HashMap<std::sync::Arc<str>, super::FileIconOverride> = HashMap::new();
2563        let opts = super::TabDisplayOptions {
2564            variant: DraggableTabBarVariant::Bordered,
2565            divider: '|',
2566            accent_symbol: '|',
2567            close_symbol: "x",
2568            show_close_buttons: false,
2569            tab_max_width: None,
2570            overflow: DraggableTabBarOverflow::ShrinkThenScroll { min_tab_width: 5 },
2571            show_file_icons: false,
2572            file_icon_style: crate::widgets::FileIconStyle::NerdFont,
2573            file_icon_palette: &FileIconPalette::default(),
2574            file_icon_overrides: &overrides,
2575            width_lock: None,
2576        };
2577
2578        let hit = DraggableTabBar::hit_target_at_view_col(
2579            &tabs,
2580            &opts,
2581            &super::TabViewportOptions {
2582                scroll_offset: 0,
2583                viewport_width: 20,
2584                show_overflow_controls: true,
2585                overflow_labels: Default::default(),
2586            },
2587            7,
2588        );
2589
2590        assert!(matches!(
2591            hit,
2592            Some(super::DraggableTabHitTarget::Tab(super::DraggableTabHit {
2593                index: 1,
2594                part: DraggableTabHitPart::Body,
2595            }))
2596        ));
2597    }
2598
2599    #[test]
2600    fn overflow_right_label_has_left_padding() {
2601        assert_eq!(
2602            super::OverflowLabels::default()
2603                .label(super::OverflowControlSide::Right, 1)
2604                .as_ref(),
2605            "  1"
2606        );
2607    }
2608
2609    #[test]
2610    fn custom_overflow_labels_replace_defaults() {
2611        let labels = super::OverflowLabels {
2612            left: Some(std::sync::Arc::new(|hidden| {
2613                std::sync::Arc::from(format!("<{hidden}"))
2614            })),
2615            right: None,
2616        };
2617
2618        assert_eq!(
2619            labels.label(super::OverflowControlSide::Left, 3).as_ref(),
2620            "<3"
2621        );
2622        assert_eq!(labels.width(super::OverflowControlSide::Left, 3), 2);
2623        assert_eq!(
2624            labels.label(super::OverflowControlSide::Right, 3).as_ref(),
2625            super::OverflowLabels::default()
2626                .label(super::OverflowControlSide::Right, 3)
2627                .as_ref()
2628        );
2629    }
2630
2631    #[test]
2632    fn custom_overflow_labels_drive_layout_and_hit_testing() {
2633        let tabs = vec![
2634            DraggableTab::new("abcdef"),
2635            DraggableTab::new("abcdef"),
2636            DraggableTab::new("abcdef"),
2637        ];
2638        let overrides: HashMap<std::sync::Arc<str>, super::FileIconOverride> = HashMap::new();
2639        let opts = super::TabDisplayOptions {
2640            variant: DraggableTabBarVariant::Bordered,
2641            divider: '|',
2642            accent_symbol: '|',
2643            close_symbol: "x",
2644            show_close_buttons: false,
2645            tab_max_width: None,
2646            overflow: super::DraggableTabBarOverflow::Scroll,
2647            show_file_icons: false,
2648            file_icon_style: crate::widgets::FileIconStyle::NerdFont,
2649            file_icon_palette: &FileIconPalette::default(),
2650            file_icon_overrides: &overrides,
2651            width_lock: None,
2652        };
2653        // Five columns wide, versus three for the default right label.
2654        let viewport = super::TabViewportOptions {
2655            scroll_offset: 0,
2656            viewport_width: 20,
2657            show_overflow_controls: true,
2658            overflow_labels: super::OverflowLabels {
2659                left: None,
2660                right: Some(std::sync::Arc::new(|hidden| {
2661                    std::sync::Arc::from(format!(" +{hidden}> "))
2662                })),
2663            },
2664        };
2665
2666        let layout = DraggableTabBar::viewport_layout(&tabs, &opts, &viewport);
2667        let right = layout.right_control.clone().expect("right control");
2668        assert_eq!(right.label.as_ref(), " +2> ");
2669        assert_eq!(right.start, 15);
2670        assert_eq!(right.end, 20);
2671        assert_eq!(layout.content_width, 15);
2672
2673        assert!(matches!(
2674            DraggableTabBar::hit_target_at_view_col(&tabs, &opts, &viewport, 15),
2675            Some(super::DraggableTabHitTarget::Overflow(
2676                super::OverflowControlSide::Right
2677            ))
2678        ));
2679        // The wider label pushes the hit target left; the default label would not reach here.
2680        assert!(!matches!(
2681            DraggableTabBar::hit_target_at_view_col(&tabs, &opts, &viewport, 14),
2682            Some(super::DraggableTabHitTarget::Overflow(_))
2683        ));
2684    }
2685
2686    #[test]
2687    fn overflow_label_builders_feed_the_node() {
2688        let bar = DraggableTabBar::new()
2689            .overflow_left_label(|hidden| std::sync::Arc::from(format!("[{hidden}")))
2690            .overflow_right_label(|hidden| std::sync::Arc::from(format!("{hidden}]")));
2691        let node: crate::widgets::internal::DraggableTabBarNode = bar.into();
2692        let viewport = node.viewport_options(20);
2693
2694        assert_eq!(
2695            viewport
2696                .overflow_labels
2697                .label(super::OverflowControlSide::Left, 4)
2698                .as_ref(),
2699            "[4"
2700        );
2701        assert_eq!(
2702            viewport
2703                .overflow_labels
2704                .label(super::OverflowControlSide::Right, 4)
2705                .as_ref(),
2706            "4]"
2707        );
2708    }
2709
2710    #[test]
2711    fn empty_text_builders_feed_the_node() {
2712        let bar = DraggableTabBar::new()
2713            .empty_text("No open tabs")
2714            .empty_text_style(Style::default().dim());
2715        let node: crate::widgets::internal::DraggableTabBarNode = bar.into();
2716
2717        assert_eq!(node.empty_text.as_deref(), Some("No open tabs"));
2718        assert_eq!(node.empty_text_style, Style::default().dim());
2719    }
2720
2721    #[test]
2722    fn stepping_right_reaches_visible_right_edge() {
2723        let tabs = vec![
2724            DraggableTab::new("alpha.rs"),
2725            DraggableTab::new("beta-long-file-name.rs"),
2726            DraggableTab::new("gamma.rs"),
2727            DraggableTab::new("delta.rs"),
2728            DraggableTab::new("epsilon.rs"),
2729        ];
2730        let overrides: HashMap<std::sync::Arc<str>, super::FileIconOverride> = HashMap::new();
2731        let mut offset = 0usize;
2732        let viewport_width = 24usize;
2733        let disp_opts = super::TabDisplayOptions {
2734            variant: DraggableTabBarVariant::Bordered,
2735            divider: '|',
2736            accent_symbol: '|',
2737            close_symbol: "x",
2738            show_close_buttons: false,
2739            tab_max_width: None,
2740            overflow: super::DraggableTabBarOverflow::Scroll,
2741            show_file_icons: false,
2742            file_icon_style: crate::widgets::FileIconStyle::NerdFont,
2743            file_icon_palette: &FileIconPalette::default(),
2744            file_icon_overrides: &overrides,
2745            width_lock: None,
2746        };
2747
2748        for _ in 0..64 {
2749            let next = DraggableTabBar::scroll_offset_for_step(
2750                &tabs,
2751                &disp_opts,
2752                &super::TabViewportOptions {
2753                    scroll_offset: offset,
2754                    viewport_width,
2755                    show_overflow_controls: true,
2756                    overflow_labels: Default::default(),
2757                },
2758                true,
2759                super::TAB_SCROLL_STEP_CHARS,
2760            );
2761            if next == offset {
2762                break;
2763            }
2764            offset = next;
2765        }
2766
2767        let layout = DraggableTabBar::viewport_layout(
2768            &tabs,
2769            &disp_opts,
2770            &super::TabViewportOptions {
2771                scroll_offset: offset,
2772                viewport_width,
2773                show_overflow_controls: true,
2774                overflow_labels: Default::default(),
2775            },
2776        );
2777
2778        assert_eq!(
2779            layout.hidden_right, 0,
2780            "offset={} content_width={} hidden_left={} layout={:?}",
2781            layout.offset, layout.content_width, layout.hidden_left, layout
2782        );
2783    }
2784}