Skip to main content

par_term/
tab_bar_ui.rs

1//! Tab bar UI using egui
2//!
3//! Provides a visual tab bar for switching between terminal tabs.
4
5use crate::config::{Config, TabBarMode, TabBarPosition};
6use crate::tab::{TabId, TabManager};
7
8/// Width reserved for the profile chevron (▾) button in the tab bar split button.
9/// Accounts for the button min_size (14px) plus egui button frame padding (~4px each side).
10const CHEVRON_RESERVED: f32 = 28.0;
11
12/// Styled text segment for rich tab titles
13#[derive(Debug, Clone, PartialEq, Eq)]
14struct StyledSegment {
15    text: String,
16    bold: bool,
17    italic: bool,
18    underline: bool,
19    color: Option<[u8; 3]>,
20}
21
22#[derive(Clone, Copy, Debug)]
23struct TitleStyle {
24    bold: bool,
25    italic: bool,
26    underline: bool,
27    color: Option<[u8; 3]>,
28}
29
30/// Actions that can be triggered from the tab bar
31#[derive(Debug, Clone, PartialEq)]
32pub enum TabBarAction {
33    /// No action
34    None,
35    /// Switch to a specific tab
36    SwitchTo(TabId),
37    /// Close a specific tab
38    Close(TabId),
39    /// Create a new tab
40    NewTab,
41    /// Create a new tab from a specific profile
42    NewTabWithProfile(crate::profile::ProfileId),
43    /// Reorder a tab to a new position
44    Reorder(TabId, usize),
45    /// Set custom color for a tab
46    SetColor(TabId, [u8; 3]),
47    /// Clear custom color for a tab (revert to default)
48    ClearColor(TabId),
49    /// Duplicate a specific tab
50    Duplicate(TabId),
51    /// Toggle the AI assistant panel
52    ToggleAssistantPanel,
53}
54
55/// Tab bar UI state
56pub struct TabBarUI {
57    /// Currently hovered tab ID
58    pub hovered_tab: Option<TabId>,
59    /// Tab where close button is hovered
60    pub close_hovered: Option<TabId>,
61    /// Whether a drag is in progress
62    drag_in_progress: bool,
63    /// Tab being dragged
64    dragging_tab: Option<TabId>,
65    /// Cached title of the tab being dragged (for ghost rendering)
66    dragging_title: String,
67    /// Cached color of the tab being dragged
68    dragging_color: Option<[u8; 3]>,
69    /// Width of the tab being dragged (for ghost rendering)
70    dragging_tab_width: f32,
71    /// Visual indicator for where the dragged tab would be inserted
72    drop_target_index: Option<usize>,
73    /// Per-frame cache of tab rects for drop target calculation
74    tab_rects: Vec<(TabId, egui::Rect)>,
75    /// Tab ID for which context menu is open
76    context_menu_tab: Option<TabId>,
77    /// Position where context menu was opened
78    context_menu_pos: egui::Pos2,
79    /// Frame when context menu was opened (to avoid closing on same frame)
80    context_menu_opened_frame: u64,
81    /// Color being edited in the color picker (for the context menu)
82    editing_color: [u8; 3],
83    /// Horizontal scroll offset for tabs (in pixels)
84    scroll_offset: f32,
85    /// Whether the new-tab profile popup is open
86    pub show_new_tab_profile_menu: bool,
87}
88
89impl TabBarUI {
90    /// Create a new tab bar UI
91    pub fn new() -> Self {
92        Self {
93            hovered_tab: None,
94            close_hovered: None,
95            drag_in_progress: false,
96            dragging_tab: None,
97            dragging_title: String::new(),
98            dragging_color: None,
99            dragging_tab_width: 0.0,
100            drop_target_index: None,
101            tab_rects: Vec::new(),
102            context_menu_tab: None,
103            context_menu_pos: egui::Pos2::ZERO,
104            context_menu_opened_frame: 0,
105            editing_color: [100, 100, 100],
106            scroll_offset: 0.0,
107            show_new_tab_profile_menu: false,
108        }
109    }
110
111    /// Check if tab bar should be visible
112    pub fn should_show(&self, tab_count: usize, mode: TabBarMode) -> bool {
113        match mode {
114            TabBarMode::Always => true,
115            TabBarMode::WhenMultiple => tab_count > 1,
116            TabBarMode::Never => false,
117        }
118    }
119
120    /// Check if a drag operation is in progress
121    pub fn is_dragging(&self) -> bool {
122        self.drag_in_progress
123    }
124
125    /// Render the tab bar and return any action triggered
126    pub fn render(
127        &mut self,
128        ctx: &egui::Context,
129        tabs: &TabManager,
130        config: &Config,
131        profiles: &crate::profile::ProfileManager,
132    ) -> TabBarAction {
133        let tab_count = tabs.tab_count();
134
135        // Don't show if configured to hide
136        if !self.should_show(tab_count, config.tab_bar_mode) {
137            return TabBarAction::None;
138        }
139
140        match config.tab_bar_position {
141            TabBarPosition::Left => self.render_vertical(ctx, tabs, config, profiles),
142            _ => self.render_horizontal(ctx, tabs, config, profiles),
143        }
144    }
145
146    /// Render the tab bar in horizontal layout (top or bottom)
147    fn render_horizontal(
148        &mut self,
149        ctx: &egui::Context,
150        tabs: &TabManager,
151        config: &Config,
152        profiles: &crate::profile::ProfileManager,
153    ) -> TabBarAction {
154        let tab_count = tabs.tab_count();
155
156        // Clear per-frame tab rect cache
157        self.tab_rects.clear();
158
159        let mut action = TabBarAction::None;
160        let active_tab_id = tabs.active_tab_id();
161
162        // Layout constants
163        let tab_spacing = 4.0;
164        let new_tab_btn_width = 28.0
165            + if profiles.is_empty() {
166                0.0
167            } else {
168                CHEVRON_RESERVED
169            };
170        let scroll_btn_width = 24.0;
171
172        let bar_bg = config.tab_bar_background;
173        let frame =
174            egui::Frame::NONE.fill(egui::Color32::from_rgb(bar_bg[0], bar_bg[1], bar_bg[2]));
175
176        let panel = if config.tab_bar_position == TabBarPosition::Bottom {
177            egui::TopBottomPanel::bottom("tab_bar").exact_height(config.tab_bar_height)
178        } else {
179            egui::TopBottomPanel::top("tab_bar").exact_height(config.tab_bar_height)
180        };
181
182        panel.frame(frame).show(ctx, |ui| {
183            let total_bar_width = ui.available_width();
184
185            // Calculate minimum total width needed for all tabs at min_width
186            let min_total_tabs_width = if tab_count > 0 {
187                tab_count as f32 * config.tab_min_width + (tab_count - 1) as f32 * tab_spacing
188            } else {
189                0.0
190            };
191
192            // Available width for tabs (without scroll buttons initially)
193            let base_tabs_area_width = total_bar_width - new_tab_btn_width - tab_spacing;
194
195            // Determine if scrolling is needed
196            let needs_scroll = tab_count > 0 && min_total_tabs_width > base_tabs_area_width;
197
198            // Actual tabs area width (accounting for scroll buttons if needed)
199            let tabs_area_width = if needs_scroll {
200                base_tabs_area_width - 2.0 * scroll_btn_width - 2.0 * tab_spacing
201            } else {
202                base_tabs_area_width
203            };
204
205            // Calculate tab width
206            let tab_width = if tab_count == 0 || needs_scroll {
207                config.tab_min_width
208            } else if config.tab_stretch_to_fill {
209                let total_spacing = (tab_count - 1) as f32 * tab_spacing;
210                let stretched = (tabs_area_width - total_spacing) / tab_count as f32;
211                stretched.max(config.tab_min_width)
212            } else {
213                config.tab_min_width
214            };
215
216            // Calculate max scroll offset
217            let max_scroll = if needs_scroll {
218                (min_total_tabs_width - tabs_area_width).max(0.0)
219            } else {
220                0.0
221            };
222
223            // Clamp scroll offset
224            self.scroll_offset = self.scroll_offset.clamp(0.0, max_scroll);
225
226            ui.horizontal(|ui| {
227                ui.spacing_mut().item_spacing = egui::vec2(tab_spacing, 0.0);
228
229                if needs_scroll {
230                    // Left scroll button
231                    let can_scroll_left = self.scroll_offset > 0.0;
232                    let left_btn = ui.add_enabled(
233                        can_scroll_left,
234                        egui::Button::new("◀")
235                            .min_size(egui::vec2(scroll_btn_width, config.tab_bar_height - 4.0))
236                            .fill(egui::Color32::TRANSPARENT),
237                    );
238                    if left_btn.clicked() {
239                        self.scroll_offset =
240                            (self.scroll_offset - tab_width - tab_spacing).max(0.0);
241                    }
242
243                    // Scrollable tab area
244                    let scroll_area_response = egui::ScrollArea::horizontal()
245                        .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
246                        .max_width(tabs_area_width)
247                        .horizontal_scroll_offset(self.scroll_offset)
248                        .show(ui, |ui| {
249                            ui.horizontal(|ui| {
250                                ui.spacing_mut().item_spacing = egui::vec2(tab_spacing, 0.0);
251
252                                for (index, tab) in tabs.tabs().iter().enumerate() {
253                                    let is_active = Some(tab.id) == active_tab_id;
254                                    let is_bell_active = tab.is_bell_active();
255                                    let (tab_action, tab_rect) = self.render_tab_with_width(
256                                        ui,
257                                        tab.id,
258                                        index,
259                                        &tab.title,
260                                        tab.profile_icon.as_deref(),
261                                        is_active,
262                                        tab.has_activity,
263                                        is_bell_active,
264                                        tab.custom_color,
265                                        config,
266                                        tab_width,
267                                        tab_count,
268                                    );
269                                    self.tab_rects.push((tab.id, tab_rect));
270
271                                    if tab_action != TabBarAction::None {
272                                        action = tab_action;
273                                    }
274                                }
275                            });
276                        });
277
278                    // Update scroll offset from scroll area
279                    self.scroll_offset = scroll_area_response.state.offset.x;
280
281                    // Right scroll button
282                    let can_scroll_right = self.scroll_offset < max_scroll;
283                    let right_btn = ui.add_enabled(
284                        can_scroll_right,
285                        egui::Button::new("▶")
286                            .min_size(egui::vec2(scroll_btn_width, config.tab_bar_height - 4.0))
287                            .fill(egui::Color32::TRANSPARENT),
288                    );
289                    if right_btn.clicked() {
290                        self.scroll_offset =
291                            (self.scroll_offset + tab_width + tab_spacing).min(max_scroll);
292                    }
293                } else {
294                    // No scrolling needed - render all tabs with equal width
295                    for (index, tab) in tabs.tabs().iter().enumerate() {
296                        let is_active = Some(tab.id) == active_tab_id;
297                        let is_bell_active = tab.is_bell_active();
298                        let (tab_action, tab_rect) = self.render_tab_with_width(
299                            ui,
300                            tab.id,
301                            index,
302                            &tab.title,
303                            tab.profile_icon.as_deref(),
304                            is_active,
305                            tab.has_activity,
306                            is_bell_active,
307                            tab.custom_color,
308                            config,
309                            tab_width,
310                            tab_count,
311                        );
312                        self.tab_rects.push((tab.id, tab_rect));
313
314                        if tab_action != TabBarAction::None {
315                            action = tab_action;
316                        }
317                    }
318                }
319
320                // New tab split button: [+][▾]
321                ui.add_space(tab_spacing);
322
323                // Use zero spacing between + and ▾ so they render as one split button
324                let prev_spacing = ui.spacing().item_spacing.x;
325                ui.spacing_mut().item_spacing.x = 0.0;
326
327                // "+" button — creates default tab
328                let plus_btn = ui.add(
329                    egui::Button::new("+")
330                        .min_size(egui::vec2(28.0, config.tab_bar_height - 4.0))
331                        .fill(egui::Color32::TRANSPARENT),
332                );
333                if plus_btn.clicked_by(egui::PointerButton::Primary) {
334                    action = TabBarAction::NewTab;
335                }
336                if plus_btn.hovered() {
337                    #[cfg(target_os = "macos")]
338                    plus_btn.on_hover_text("New Tab (Cmd+T)");
339                    #[cfg(not(target_os = "macos"))]
340                    plus_btn.on_hover_text("New Tab (Ctrl+Shift+T)");
341                }
342
343                // "▾" chevron — opens profile dropdown (only when profiles exist)
344                if !profiles.is_empty() {
345                    let chevron_btn = ui.add(
346                        egui::Button::new("⏷")
347                            .min_size(egui::vec2(14.0, config.tab_bar_height - 4.0))
348                            .fill(egui::Color32::TRANSPARENT),
349                    );
350                    if chevron_btn.clicked_by(egui::PointerButton::Primary) {
351                        self.show_new_tab_profile_menu = !self.show_new_tab_profile_menu;
352                    }
353                    if chevron_btn.hovered() {
354                        chevron_btn.on_hover_text("New tab from profile");
355                    }
356                }
357
358                // Restore original spacing
359                ui.spacing_mut().item_spacing.x = prev_spacing;
360            });
361
362            // Handle drag feedback and drop detection (outside horizontal layout
363            // so we can paint over the tab bar)
364            if self.drag_in_progress {
365                let drag_action = self.render_drag_feedback(ui, config);
366                if drag_action != TabBarAction::None {
367                    action = drag_action;
368                }
369            }
370        });
371
372        // Render floating ghost tab during drag (must be outside the panel)
373        if self.drag_in_progress && self.dragging_tab.is_some() {
374            self.render_ghost_tab(ctx, config);
375        }
376
377        // Handle context menu (color picker popup)
378        if let Some(context_tab_id) = self.context_menu_tab {
379            let menu_action = self.render_context_menu(ctx, context_tab_id);
380            if menu_action != TabBarAction::None {
381                action = menu_action;
382            }
383        }
384
385        // Render new-tab profile menu if open
386        let menu_action = self.render_new_tab_profile_menu(ctx, profiles, config);
387        if menu_action != TabBarAction::None {
388            action = menu_action;
389        }
390
391        action
392    }
393
394    /// Render the tab bar in vertical layout (left side panel)
395    fn render_vertical(
396        &mut self,
397        ctx: &egui::Context,
398        tabs: &TabManager,
399        config: &Config,
400        profiles: &crate::profile::ProfileManager,
401    ) -> TabBarAction {
402        let tab_count = tabs.tab_count();
403
404        self.tab_rects.clear();
405
406        let mut action = TabBarAction::None;
407        let active_tab_id = tabs.active_tab_id();
408
409        let bar_bg = config.tab_bar_background;
410        let tab_spacing = 4.0;
411        let tab_height = config.tab_bar_height; // Reuse height config for per-tab row height
412
413        egui::SidePanel::left("tab_bar")
414            .exact_width(config.tab_bar_width)
415            .frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(bar_bg[0], bar_bg[1], bar_bg[2])))
416            .show(ctx, |ui| {
417                egui::ScrollArea::vertical()
418                    .scroll_bar_visibility(
419                        egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
420                    )
421                    .show(ui, |ui| {
422                        ui.vertical(|ui| {
423                            ui.spacing_mut().item_spacing = egui::vec2(0.0, tab_spacing);
424
425                            for (index, tab) in tabs.tabs().iter().enumerate() {
426                                let is_active = Some(tab.id) == active_tab_id;
427                                let is_bell_active = tab.is_bell_active();
428                                let (tab_action, tab_rect) = self.render_vertical_tab(
429                                    ui,
430                                    tab.id,
431                                    index,
432                                    &tab.title,
433                                    tab.profile_icon.as_deref(),
434                                    is_active,
435                                    tab.has_activity,
436                                    is_bell_active,
437                                    tab.custom_color,
438                                    config,
439                                    tab_height,
440                                    tab_count,
441                                );
442                                self.tab_rects.push((tab.id, tab_rect));
443
444                                if tab_action != TabBarAction::None {
445                                    action = tab_action;
446                                }
447                            }
448
449                            // New tab split button
450                            ui.add_space(tab_spacing);
451                            ui.horizontal(|ui| {
452                                // Zero spacing between + and ▾
453                                ui.spacing_mut().item_spacing.x = 0.0;
454
455                                let chevron_space = if profiles.is_empty() {
456                                    0.0
457                                } else {
458                                    CHEVRON_RESERVED
459                                };
460                                let plus_btn = ui.add(
461                                    egui::Button::new("+")
462                                        .min_size(egui::vec2(
463                                            ui.available_width() - chevron_space,
464                                            tab_height - 4.0,
465                                        ))
466                                        .fill(egui::Color32::TRANSPARENT),
467                                );
468                                if plus_btn.clicked_by(egui::PointerButton::Primary) {
469                                    action = TabBarAction::NewTab;
470                                }
471                                if plus_btn.hovered() {
472                                    #[cfg(target_os = "macos")]
473                                    plus_btn.on_hover_text("New Tab (Cmd+T)");
474                                    #[cfg(not(target_os = "macos"))]
475                                    plus_btn.on_hover_text("New Tab (Ctrl+Shift+T)");
476                                }
477
478                                if !profiles.is_empty() {
479                                    let chevron_btn = ui.add(
480                                        egui::Button::new("⏷")
481                                            .min_size(egui::vec2(14.0, tab_height - 4.0))
482                                            .fill(egui::Color32::TRANSPARENT),
483                                    );
484                                    if chevron_btn.clicked_by(egui::PointerButton::Primary) {
485                                        self.show_new_tab_profile_menu =
486                                            !self.show_new_tab_profile_menu;
487                                    }
488                                    if chevron_btn.hovered() {
489                                        chevron_btn.on_hover_text("New tab from profile");
490                                    }
491                                }
492                            });
493                        });
494                    });
495
496                // Handle drag feedback for vertical mode
497                if self.drag_in_progress {
498                    let drag_action = self.render_vertical_drag_feedback(ui, config);
499                    if drag_action != TabBarAction::None {
500                        action = drag_action;
501                    }
502                }
503            });
504
505        // Render floating ghost tab during drag
506        if self.drag_in_progress && self.dragging_tab.is_some() {
507            self.render_ghost_tab(ctx, config);
508        }
509
510        // Handle context menu
511        if let Some(context_tab_id) = self.context_menu_tab {
512            let menu_action = self.render_context_menu(ctx, context_tab_id);
513            if menu_action != TabBarAction::None {
514                action = menu_action;
515            }
516        }
517
518        // Render new-tab profile menu if open
519        let menu_action = self.render_new_tab_profile_menu(ctx, profiles, config);
520        if menu_action != TabBarAction::None {
521            action = menu_action;
522        }
523
524        action
525    }
526
527    /// Render a single tab as a full-width row in the vertical tab bar
528    #[allow(clippy::too_many_arguments)]
529    fn render_vertical_tab(
530        &mut self,
531        ui: &mut egui::Ui,
532        id: TabId,
533        _index: usize,
534        title: &str,
535        profile_icon: Option<&str>,
536        is_active: bool,
537        has_activity: bool,
538        is_bell_active: bool,
539        custom_color: Option<[u8; 3]>,
540        config: &Config,
541        tab_height: f32,
542        tab_count: usize,
543    ) -> (TabBarAction, egui::Rect) {
544        let mut action = TabBarAction::None;
545
546        let is_hovered = self.hovered_tab == Some(id);
547        let is_being_dragged = self.dragging_tab == Some(id) && self.drag_in_progress;
548        let should_dim =
549            is_being_dragged || (config.dim_inactive_tabs && !is_active && !is_hovered);
550        let opacity = if is_being_dragged {
551            100
552        } else if should_dim {
553            (config.inactive_tab_opacity * 255.0) as u8
554        } else {
555            255
556        };
557
558        // Whether this inactive tab should render as outline-only (no fill)
559        let outline_only = config.tab_inactive_outline_only && !is_active;
560
561        let bg_color = if outline_only {
562            egui::Color32::TRANSPARENT
563        } else if let Some(custom) = custom_color {
564            if is_active {
565                egui::Color32::from_rgba_unmultiplied(custom[0], custom[1], custom[2], 255)
566            } else if is_hovered {
567                let lighten = |c: u8| c.saturating_add(20);
568                egui::Color32::from_rgba_unmultiplied(
569                    lighten(custom[0]),
570                    lighten(custom[1]),
571                    lighten(custom[2]),
572                    255,
573                )
574            } else {
575                let darken = |c: u8| c.saturating_sub(30);
576                egui::Color32::from_rgba_unmultiplied(
577                    darken(custom[0]),
578                    darken(custom[1]),
579                    darken(custom[2]),
580                    opacity,
581                )
582            }
583        } else if is_active {
584            let c = config.tab_active_background;
585            egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
586        } else if is_hovered {
587            let c = config.tab_hover_background;
588            egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
589        } else {
590            let c = config.tab_inactive_background;
591            egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
592        };
593
594        let full_width = ui.available_width();
595        let (tab_rect, _) =
596            ui.allocate_exact_size(egui::vec2(full_width, tab_height), egui::Sense::hover());
597
598        let tab_draw_rect = tab_rect.shrink2(egui::vec2(2.0, 1.0));
599        let tab_rounding = 4.0;
600        if ui.is_rect_visible(tab_rect) {
601            ui.painter()
602                .rect_filled(tab_draw_rect, tab_rounding, bg_color);
603
604            // Draw outline border for outline-only inactive tabs (brighten on hover)
605            if outline_only {
606                let base = if let Some(custom) = custom_color {
607                    custom
608                } else {
609                    config.tab_border_color
610                };
611                let c = if is_hovered {
612                    let brighten = |v: u8| v.saturating_add(60);
613                    [brighten(base[0]), brighten(base[1]), brighten(base[2])]
614                } else {
615                    base
616                };
617                let border_width = config.tab_border_width.max(1.0);
618                ui.painter().rect_stroke(
619                    tab_draw_rect,
620                    tab_rounding,
621                    egui::Stroke::new(border_width, egui::Color32::from_rgb(c[0], c[1], c[2])),
622                    egui::StrokeKind::Middle,
623                );
624            }
625
626            // Active indicator: left edge bar instead of bottom underline
627            if is_active {
628                let c = if let Some(custom) = custom_color {
629                    let lighten = |v: u8| v.saturating_add(50);
630                    [lighten(custom[0]), lighten(custom[1]), lighten(custom[2])]
631                } else {
632                    config.tab_active_indicator
633                };
634                let indicator_rect = egui::Rect::from_min_size(
635                    tab_draw_rect.left_top(),
636                    egui::vec2(3.0, tab_draw_rect.height()),
637                );
638                ui.painter().rect_filled(
639                    indicator_rect,
640                    egui::CornerRadius {
641                        nw: tab_rounding as u8,
642                        sw: tab_rounding as u8,
643                        ne: 0,
644                        se: 0,
645                    },
646                    egui::Color32::from_rgb(c[0], c[1], c[2]),
647                );
648            }
649
650            // Content
651            let content_rect = tab_rect.shrink2(egui::vec2(8.0, 2.0));
652            let mut content_ui = ui.new_child(
653                egui::UiBuilder::new()
654                    .max_rect(content_rect)
655                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
656            );
657
658            content_ui.horizontal(|ui| {
659                if is_bell_active {
660                    let c = config.tab_bell_indicator;
661                    ui.colored_label(egui::Color32::from_rgb(c[0], c[1], c[2]), "🔔");
662                    ui.add_space(2.0);
663                } else if has_activity && !is_active {
664                    let c = config.tab_activity_indicator;
665                    ui.colored_label(egui::Color32::from_rgb(c[0], c[1], c[2]), "•");
666                    ui.add_space(2.0);
667                }
668
669                // Profile icon
670                let icon_width = if let Some(icon) = profile_icon {
671                    ui.label(icon);
672                    ui.add_space(2.0);
673                    18.0
674                } else {
675                    0.0
676                };
677
678                let text_color = if is_active {
679                    let c = config.tab_active_text;
680                    egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
681                } else {
682                    let c = config.tab_inactive_text;
683                    egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
684                };
685
686                // Truncate title to fit available width
687                let close_width = if config.tab_show_close_button {
688                    20.0
689                } else {
690                    0.0
691                };
692                let available = (full_width - 16.0 - icon_width - close_width).max(20.0);
693                let base_font_id = ui.style().text_styles[&egui::TextStyle::Button].clone();
694                let max_chars = estimate_max_chars(ui, &base_font_id, available);
695                let display_title = truncate_plain(title, max_chars);
696                ui.label(egui::RichText::new(display_title).color(text_color));
697            });
698
699            // Close button at right edge
700            if config.tab_show_close_button {
701                let close_size = 16.0;
702                let close_rect = egui::Rect::from_min_size(
703                    egui::pos2(
704                        tab_rect.right() - close_size - 4.0,
705                        tab_rect.center().y - close_size / 2.0,
706                    ),
707                    egui::vec2(close_size, close_size),
708                );
709                let pointer_pos = ui.ctx().input(|i| i.pointer.hover_pos());
710                let close_hovered = pointer_pos.is_some_and(|pos| close_rect.contains(pos));
711
712                if close_hovered {
713                    self.close_hovered = Some(id);
714                } else if self.close_hovered == Some(id) {
715                    self.close_hovered = None;
716                }
717
718                let close_color = if self.close_hovered == Some(id) {
719                    let c = config.tab_close_button_hover;
720                    egui::Color32::from_rgb(c[0], c[1], c[2])
721                } else {
722                    let c = config.tab_close_button;
723                    egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
724                };
725
726                ui.painter().text(
727                    close_rect.center(),
728                    egui::Align2::CENTER_CENTER,
729                    "×",
730                    egui::FontId::proportional(12.0),
731                    close_color,
732                );
733            }
734        }
735
736        // Handle click and drag
737        let tab_response = ui.interact(
738            tab_rect,
739            egui::Id::new(("tab_click", id)),
740            egui::Sense::click_and_drag(),
741        );
742
743        let pointer_in_tab = tab_response.hovered();
744        let clicked = tab_response.clicked_by(egui::PointerButton::Primary);
745
746        if tab_count > 1
747            && !self.drag_in_progress
748            && self.close_hovered != Some(id)
749            && tab_response.drag_started_by(egui::PointerButton::Primary)
750        {
751            self.drag_in_progress = true;
752            self.dragging_tab = Some(id);
753            self.dragging_title = title.to_string();
754            self.dragging_color = custom_color;
755            self.dragging_tab_width = full_width;
756        }
757
758        let is_dragging_this = self.dragging_tab == Some(id) && self.drag_in_progress;
759
760        if clicked
761            && !is_dragging_this
762            && action == TabBarAction::None
763            && self.close_hovered != Some(id)
764        {
765            action = TabBarAction::SwitchTo(id);
766        }
767
768        if clicked && self.close_hovered == Some(id) {
769            action = TabBarAction::Close(id);
770        }
771
772        if tab_response.secondary_clicked() {
773            self.editing_color = custom_color.unwrap_or([100, 100, 100]);
774            self.context_menu_tab = Some(id);
775            if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
776                self.context_menu_pos = pos;
777            }
778            self.context_menu_opened_frame = ui.ctx().cumulative_frame_nr();
779        }
780
781        if pointer_in_tab {
782            self.hovered_tab = Some(id);
783        } else if self.hovered_tab == Some(id) {
784            self.hovered_tab = None;
785        }
786
787        (action, tab_rect)
788    }
789
790    /// Render drag feedback for vertical tab bar layout
791    fn render_vertical_drag_feedback(
792        &mut self,
793        ui: &mut egui::Ui,
794        config: &Config,
795    ) -> TabBarAction {
796        let mut action = TabBarAction::None;
797
798        let dragging_id = match self.dragging_tab {
799            Some(id) => id,
800            None => {
801                self.drag_in_progress = false;
802                return action;
803            }
804        };
805
806        if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
807            self.drag_in_progress = false;
808            self.dragging_tab = None;
809            self.drop_target_index = None;
810            return action;
811        }
812
813        ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
814
815        let drag_source_index = self.tab_rects.iter().position(|(id, _)| *id == dragging_id);
816
817        if let Some(pointer_pos) = ui.ctx().input(|i| i.pointer.hover_pos()) {
818            let mut insert_index = self.tab_rects.len();
819            for (i, (_id, rect)) in self.tab_rects.iter().enumerate() {
820                if pointer_pos.y < rect.center().y {
821                    insert_index = i;
822                    break;
823                }
824            }
825
826            let is_noop =
827                drag_source_index.is_some_and(|src| insert_index == src || insert_index == src + 1);
828
829            if is_noop {
830                self.drop_target_index = None;
831            } else {
832                self.drop_target_index = Some(insert_index);
833
834                // Horizontal indicator line for vertical layout
835                let indicator_y = if insert_index < self.tab_rects.len() {
836                    self.tab_rects[insert_index].1.top() - 2.0
837                } else if let Some(last) = self.tab_rects.last() {
838                    last.1.bottom() + 2.0
839                } else {
840                    0.0
841                };
842
843                let indicator_color = egui::Color32::from_rgb(80, 160, 255);
844                let left = config.tab_bar_width * 0.05;
845                let right = config.tab_bar_width * 0.95;
846
847                ui.painter().line_segment(
848                    [
849                        egui::pos2(left, indicator_y),
850                        egui::pos2(right, indicator_y),
851                    ],
852                    egui::Stroke::new(3.0, indicator_color),
853                );
854            }
855        }
856
857        if ui.ctx().input(|i| i.pointer.any_released()) {
858            if let Some(insert_idx) = self.drop_target_index {
859                let effective_target = if let Some(src) = drag_source_index {
860                    if insert_idx > src {
861                        insert_idx - 1
862                    } else {
863                        insert_idx
864                    }
865                } else {
866                    insert_idx
867                };
868                action = TabBarAction::Reorder(dragging_id, effective_target);
869            }
870            self.drag_in_progress = false;
871            self.dragging_tab = None;
872            self.drop_target_index = None;
873        }
874
875        action
876    }
877
878    /// Render a single tab with specified width and return any action triggered plus the tab rect
879    #[allow(clippy::too_many_arguments)]
880    fn render_tab_with_width(
881        &mut self,
882        ui: &mut egui::Ui,
883        id: TabId,
884        index: usize,
885        title: &str,
886        profile_icon: Option<&str>,
887        is_active: bool,
888        has_activity: bool,
889        is_bell_active: bool,
890        custom_color: Option<[u8; 3]>,
891        config: &Config,
892        tab_width: f32,
893        tab_count: usize,
894    ) -> (TabBarAction, egui::Rect) {
895        let mut action = TabBarAction::None;
896
897        // Determine if this tab should be dimmed
898        // Active tabs and hovered inactive tabs are NOT dimmed
899        // Also dim the tab being dragged
900        let is_hovered = self.hovered_tab == Some(id);
901        let is_being_dragged = self.dragging_tab == Some(id) && self.drag_in_progress;
902        let should_dim =
903            is_being_dragged || (config.dim_inactive_tabs && !is_active && !is_hovered);
904        let opacity = if is_being_dragged {
905            100
906        } else if should_dim {
907            (config.inactive_tab_opacity * 255.0) as u8
908        } else {
909            255
910        };
911
912        // Whether this inactive tab should render as outline-only (no fill)
913        let outline_only = config.tab_inactive_outline_only && !is_active;
914
915        // Tab background color with opacity
916        // Custom color overrides config colors for inactive/active background
917        let bg_color = if outline_only {
918            egui::Color32::TRANSPARENT
919        } else if let Some(custom) = custom_color {
920            // Use custom color with appropriate opacity/brightness adjustment
921            if is_active {
922                egui::Color32::from_rgba_unmultiplied(custom[0], custom[1], custom[2], 255)
923            } else if is_hovered {
924                // Lighten the custom color slightly for hover
925                let lighten = |c: u8| c.saturating_add(20);
926                egui::Color32::from_rgba_unmultiplied(
927                    lighten(custom[0]),
928                    lighten(custom[1]),
929                    lighten(custom[2]),
930                    255,
931                )
932            } else {
933                // Darken the custom color slightly for inactive
934                let darken = |c: u8| c.saturating_sub(30);
935                egui::Color32::from_rgba_unmultiplied(
936                    darken(custom[0]),
937                    darken(custom[1]),
938                    darken(custom[2]),
939                    opacity,
940                )
941            }
942        } else if is_active {
943            let c = config.tab_active_background;
944            egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
945        } else if is_hovered {
946            let c = config.tab_hover_background;
947            egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
948        } else {
949            let c = config.tab_inactive_background;
950            egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
951        };
952
953        // Tab frame - allocate space for the tab
954        let (tab_rect, _) = ui.allocate_exact_size(
955            egui::vec2(tab_width, config.tab_bar_height),
956            egui::Sense::hover(),
957        );
958
959        // Draw tab background with pill shape
960        // Use rounding based on tab height for a smooth pill appearance
961        // Shrink vertically so borders are fully visible within tab bar
962        let tab_draw_rect = tab_rect.shrink2(egui::vec2(0.0, 2.0));
963        let tab_rounding = tab_draw_rect.height() / 2.0;
964        if ui.is_rect_visible(tab_rect) {
965            ui.painter()
966                .rect_filled(tab_draw_rect, tab_rounding, bg_color);
967
968            // Draw border around tab
969            // Active tabs get a highlighted border using the indicator color
970            // Outline-only inactive tabs always get a border (brightened on hover)
971            if config.tab_border_width > 0.0 || is_active || outline_only {
972                let (border_color, border_width) = if is_active {
973                    // Active tab: use indicator color and slightly thicker border
974                    let c = if let Some(custom) = custom_color {
975                        // Lighten the custom color for the indicator
976                        let lighten = |v: u8| v.saturating_add(50);
977                        [lighten(custom[0]), lighten(custom[1]), lighten(custom[2])]
978                    } else {
979                        config.tab_active_indicator
980                    };
981                    (c, config.tab_border_width.max(1.5))
982                } else if outline_only {
983                    // Outline-only inactive tab: use border color, brighten on hover
984                    let base = if let Some(custom) = custom_color {
985                        custom
986                    } else {
987                        config.tab_border_color
988                    };
989                    let c = if is_hovered {
990                        let brighten = |v: u8| v.saturating_add(60);
991                        [brighten(base[0]), brighten(base[1]), brighten(base[2])]
992                    } else {
993                        base
994                    };
995                    (c, config.tab_border_width.max(1.0))
996                } else {
997                    // Inactive tabs: use normal border color
998                    (config.tab_border_color, config.tab_border_width)
999                };
1000
1001                if border_width > 0.0 {
1002                    ui.painter().rect_stroke(
1003                        tab_draw_rect,
1004                        tab_rounding,
1005                        egui::Stroke::new(
1006                            border_width,
1007                            egui::Color32::from_rgb(
1008                                border_color[0],
1009                                border_color[1],
1010                                border_color[2],
1011                            ),
1012                        ),
1013                        egui::StrokeKind::Middle,
1014                    );
1015                }
1016            }
1017
1018            // Create a child UI for the tab content
1019            let mut content_ui = ui.new_child(
1020                egui::UiBuilder::new()
1021                    .max_rect(tab_rect.shrink2(egui::vec2(8.0, 4.0)))
1022                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
1023            );
1024
1025            content_ui.horizontal(|ui| {
1026                // Bell indicator (takes priority over activity indicator)
1027                if is_bell_active {
1028                    let c = config.tab_bell_indicator;
1029                    ui.colored_label(egui::Color32::from_rgb(c[0], c[1], c[2]), "🔔");
1030                    ui.add_space(4.0);
1031                } else if has_activity && !is_active {
1032                    // Activity indicator
1033                    let c = config.tab_activity_indicator;
1034                    ui.colored_label(egui::Color32::from_rgb(c[0], c[1], c[2]), "•");
1035                    ui.add_space(4.0);
1036                }
1037
1038                // Tab index if configured
1039                if config.tab_show_index {
1040                    // We'd need to get the index, skip for now
1041                }
1042
1043                // Profile icon (from auto-applied directory/hostname profile)
1044                let icon_width = if let Some(icon) = profile_icon {
1045                    ui.label(icon);
1046                    ui.add_space(2.0);
1047                    18.0
1048                } else {
1049                    0.0
1050                };
1051
1052                // Title rendering with width-aware truncation
1053                let base_font_id = ui.style().text_styles[&egui::TextStyle::Button].clone();
1054                let indicator_width = if is_bell_active {
1055                    18.0
1056                } else if has_activity && !is_active {
1057                    14.0
1058                } else {
1059                    0.0
1060                };
1061                let hotkey_width = if index < 9 { 26.0 } else { 0.0 };
1062                let close_width = if config.tab_show_close_button {
1063                    24.0
1064                } else {
1065                    0.0
1066                };
1067                let padding = 12.0;
1068                let title_available_width = (tab_width
1069                    - indicator_width
1070                    - icon_width
1071                    - hotkey_width
1072                    - close_width
1073                    - padding)
1074                    .max(24.0);
1075
1076                let max_chars = estimate_max_chars(ui, &base_font_id, title_available_width);
1077
1078                let text_color = if is_active {
1079                    let c = config.tab_active_text;
1080                    egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
1081                } else {
1082                    let c = config.tab_inactive_text;
1083                    egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
1084                };
1085
1086                if config.tab_html_titles {
1087                    let segments = parse_html_title(title);
1088                    let truncated = truncate_segments(&segments, max_chars);
1089                    render_segments(ui, &truncated, text_color);
1090                } else {
1091                    let display_title = truncate_plain(title, max_chars);
1092                    ui.label(egui::RichText::new(display_title).color(text_color));
1093                }
1094
1095                // Hotkey indicator (only for tabs 1-9) - show on right side, leave space for close button
1096                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
1097                    // Add space for close button if shown
1098                    if config.tab_show_close_button {
1099                        ui.add_space(24.0);
1100                    }
1101                    if index < 9 {
1102                        // Use ⌘ on macOS, ^ on other platforms
1103                        let modifier_symbol = if cfg!(target_os = "macos") {
1104                            "⌘"
1105                        } else {
1106                            "^"
1107                        };
1108                        let hotkey_text = format!("{}{}", modifier_symbol, index + 1);
1109                        let hotkey_color =
1110                            egui::Color32::from_rgba_unmultiplied(180, 180, 180, opacity);
1111                        ui.label(
1112                            egui::RichText::new(hotkey_text)
1113                                .color(hotkey_color)
1114                                .size(11.0),
1115                        );
1116                    }
1117                });
1118            });
1119        }
1120
1121        // Close button - render AFTER the content so it's on top
1122        // Position at far right edge of tab
1123        let close_btn_size = 20.0;
1124        let close_btn_rect = if config.tab_show_close_button {
1125            Some(egui::Rect::from_min_size(
1126                egui::pos2(
1127                    tab_rect.right() - close_btn_size - 4.0,
1128                    tab_rect.center().y - close_btn_size / 2.0,
1129                ),
1130                egui::vec2(close_btn_size, close_btn_size),
1131            ))
1132        } else {
1133            None
1134        };
1135
1136        // Check if pointer is over close button using egui's input state
1137        let pointer_pos = ui.ctx().input(|i| i.pointer.hover_pos());
1138        let close_hovered = close_btn_rect
1139            .zip(pointer_pos)
1140            .is_some_and(|(rect, pos)| rect.contains(pos));
1141
1142        if close_hovered {
1143            self.close_hovered = Some(id);
1144        } else if self.close_hovered == Some(id) {
1145            self.close_hovered = None;
1146        }
1147
1148        // Draw close button if configured
1149        if let Some(close_rect) = close_btn_rect {
1150            let close_color = if self.close_hovered == Some(id) {
1151                let c = config.tab_close_button_hover;
1152                egui::Color32::from_rgb(c[0], c[1], c[2])
1153            } else {
1154                let c = config.tab_close_button;
1155                egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
1156            };
1157
1158            // Draw the × character centered in the close button rect
1159            ui.painter().text(
1160                close_rect.center(),
1161                egui::Align2::CENTER_CENTER,
1162                "×",
1163                egui::FontId::proportional(14.0),
1164                close_color,
1165            );
1166        }
1167
1168        // Handle tab click and drag (switch to tab / initiate drag)
1169        // Use click_and_drag sense to enable both click and drag detection
1170        let tab_response = ui.interact(
1171            tab_rect,
1172            egui::Id::new(("tab_click", id)),
1173            egui::Sense::click_and_drag(),
1174        );
1175
1176        // Use egui's response for click detection
1177        let pointer_in_tab = tab_response.hovered();
1178        let clicked = tab_response.clicked_by(egui::PointerButton::Primary);
1179
1180        // Drag initiation: only start drag if multiple tabs exist,
1181        // not hovering close button, and not already dragging
1182        if tab_count > 1
1183            && !self.drag_in_progress
1184            && self.close_hovered != Some(id)
1185            && tab_response.drag_started_by(egui::PointerButton::Primary)
1186        {
1187            self.drag_in_progress = true;
1188            self.dragging_tab = Some(id);
1189            self.dragging_title = title.to_string();
1190            self.dragging_color = custom_color;
1191            self.dragging_tab_width = tab_width;
1192        }
1193
1194        // Suppress SwitchTo while this tab is being dragged
1195        let is_dragging_this = self.dragging_tab == Some(id) && self.drag_in_progress;
1196
1197        // Detect click using clicked_by() to only respond to mouse clicks, not keyboard
1198        // This prevents Enter key from triggering tab switches when a tab has keyboard focus
1199        // IMPORTANT: Skip if close button is hovered - let the close button handle the click
1200        if clicked
1201            && !is_dragging_this
1202            && action == TabBarAction::None
1203            && self.close_hovered != Some(id)
1204        {
1205            action = TabBarAction::SwitchTo(id);
1206        }
1207
1208        // Handle close button click - check if close button is hovered
1209        if clicked && self.close_hovered == Some(id) {
1210            action = TabBarAction::Close(id);
1211        }
1212
1213        // Handle right-click for context menu
1214        if tab_response.secondary_clicked() {
1215            // Initialize editing color from custom color or a default
1216            self.editing_color = custom_color.unwrap_or([100, 100, 100]);
1217            self.context_menu_tab = Some(id);
1218            // Store click position for menu placement
1219            if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
1220                self.context_menu_pos = pos;
1221            }
1222            // Store frame number to avoid closing on same frame
1223            self.context_menu_opened_frame = ui.ctx().cumulative_frame_nr();
1224        }
1225
1226        // Update hover state (using manual detection)
1227        if pointer_in_tab {
1228            self.hovered_tab = Some(id);
1229        } else if self.hovered_tab == Some(id) {
1230            self.hovered_tab = None;
1231        }
1232
1233        (action, tab_rect)
1234    }
1235
1236    /// Render drag feedback indicator and handle drop/cancel
1237    fn render_drag_feedback(&mut self, ui: &mut egui::Ui, config: &Config) -> TabBarAction {
1238        let mut action = TabBarAction::None;
1239
1240        let dragging_id = match self.dragging_tab {
1241            Some(id) => id,
1242            None => {
1243                self.drag_in_progress = false;
1244                return action;
1245            }
1246        };
1247
1248        // Cancel on Escape
1249        if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
1250            self.drag_in_progress = false;
1251            self.dragging_tab = None;
1252            self.drop_target_index = None;
1253            return action;
1254        }
1255
1256        // Set grabbing cursor
1257        ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
1258
1259        // Find the current index of the dragged tab
1260        let drag_source_index = self.tab_rects.iter().position(|(id, _)| *id == dragging_id);
1261
1262        // Calculate insertion index from pointer position
1263        if let Some(pointer_pos) = ui.ctx().input(|i| i.pointer.hover_pos()) {
1264            let mut insert_index = self.tab_rects.len(); // default: after last tab
1265            for (i, (_id, rect)) in self.tab_rects.iter().enumerate() {
1266                if pointer_pos.x < rect.center().x {
1267                    insert_index = i;
1268                    break;
1269                }
1270            }
1271
1272            // Determine if this would be a no-op (dropping in same position)
1273            let is_noop =
1274                drag_source_index.is_some_and(|src| insert_index == src || insert_index == src + 1);
1275
1276            if is_noop {
1277                self.drop_target_index = None;
1278            } else {
1279                self.drop_target_index = Some(insert_index);
1280
1281                // Draw vertical indicator line with glow at insertion point
1282                let indicator_x = if insert_index < self.tab_rects.len() {
1283                    self.tab_rects[insert_index].1.left() - 2.0
1284                } else if let Some(last) = self.tab_rects.last() {
1285                    last.1.right() + 2.0
1286                } else {
1287                    0.0
1288                };
1289
1290                let indicator_color = egui::Color32::from_rgb(80, 160, 255);
1291                let glow_color = egui::Color32::from_rgba_unmultiplied(80, 160, 255, 50);
1292                let top = config.tab_bar_height * 0.1;
1293                let bottom = config.tab_bar_height * 0.9;
1294
1295                // Glow behind the indicator (wider, semi-transparent)
1296                ui.painter().rect_filled(
1297                    egui::Rect::from_min_max(
1298                        egui::pos2(indicator_x - 4.0, top),
1299                        egui::pos2(indicator_x + 4.0, bottom),
1300                    ),
1301                    2.0,
1302                    glow_color,
1303                );
1304
1305                // Main indicator line
1306                ui.painter().line_segment(
1307                    [
1308                        egui::pos2(indicator_x, top),
1309                        egui::pos2(indicator_x, bottom),
1310                    ],
1311                    egui::Stroke::new(3.0, indicator_color),
1312                );
1313
1314                // Small diamond/arrow at top and bottom of indicator
1315                let diamond_size = 4.0;
1316                for y in [top, bottom] {
1317                    ui.painter().circle_filled(
1318                        egui::pos2(indicator_x, y),
1319                        diamond_size,
1320                        indicator_color,
1321                    );
1322                }
1323            }
1324        }
1325
1326        // Handle drop (pointer released)
1327        if ui.ctx().input(|i| i.pointer.any_released()) {
1328            if let Some(insert_idx) = self.drop_target_index {
1329                // Convert insertion index to target index accounting for removal
1330                let effective_target = if let Some(src) = drag_source_index {
1331                    if insert_idx > src {
1332                        insert_idx - 1
1333                    } else {
1334                        insert_idx
1335                    }
1336                } else {
1337                    insert_idx
1338                };
1339                action = TabBarAction::Reorder(dragging_id, effective_target);
1340            }
1341            self.drag_in_progress = false;
1342            self.dragging_tab = None;
1343            self.drop_target_index = None;
1344        }
1345
1346        action
1347    }
1348
1349    /// Render a floating ghost tab that follows the cursor during drag
1350    fn render_ghost_tab(&self, ctx: &egui::Context, config: &Config) {
1351        let Some(pointer_pos) = ctx.input(|i| i.pointer.hover_pos()) else {
1352            return;
1353        };
1354
1355        let ghost_width = self.dragging_tab_width;
1356        let ghost_height = config.tab_bar_height - 4.0;
1357
1358        // Center ghost on pointer horizontally, offset slightly below vertically
1359        let ghost_pos = egui::pos2(
1360            pointer_pos.x - ghost_width / 2.0,
1361            pointer_pos.y - ghost_height / 2.0,
1362        );
1363
1364        // Determine ghost background color
1365        let bg_color = if let Some(custom) = self.dragging_color {
1366            egui::Color32::from_rgba_unmultiplied(custom[0], custom[1], custom[2], 200)
1367        } else {
1368            let c = config.tab_active_background;
1369            egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 200)
1370        };
1371
1372        let border_color = egui::Color32::from_rgba_unmultiplied(120, 180, 255, 200);
1373
1374        egui::Area::new(egui::Id::new("tab_drag_ghost"))
1375            .fixed_pos(ghost_pos)
1376            .order(egui::Order::Tooltip)
1377            .interactable(false)
1378            .show(ctx, |ui| {
1379                let (rect, _) = ui.allocate_exact_size(
1380                    egui::vec2(ghost_width, ghost_height),
1381                    egui::Sense::hover(),
1382                );
1383
1384                let rounding = ghost_height / 2.0;
1385
1386                // Shadow
1387                let shadow_rect = rect.translate(egui::vec2(2.0, 2.0));
1388                ui.painter().rect_filled(
1389                    shadow_rect,
1390                    rounding,
1391                    egui::Color32::from_rgba_unmultiplied(0, 0, 0, 80),
1392                );
1393
1394                // Background
1395                ui.painter().rect_filled(rect, rounding, bg_color);
1396
1397                // Border
1398                ui.painter().rect_stroke(
1399                    rect,
1400                    rounding,
1401                    egui::Stroke::new(1.5, border_color),
1402                    egui::StrokeKind::Middle,
1403                );
1404
1405                // Title text (truncated to fit)
1406                let text_color = egui::Color32::from_rgba_unmultiplied(255, 255, 255, 220);
1407                let font_id = egui::FontId::proportional(13.0);
1408                let max_text_width = ghost_width - 16.0;
1409                let galley = ui.painter().layout(
1410                    self.dragging_title.clone(),
1411                    font_id,
1412                    text_color,
1413                    max_text_width,
1414                );
1415                let text_pos =
1416                    egui::pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0);
1417                ui.painter().galley(text_pos, galley, text_color);
1418            });
1419    }
1420
1421    /// Render the new-tab profile selection popup
1422    fn render_new_tab_profile_menu(
1423        &mut self,
1424        ctx: &egui::Context,
1425        profiles: &crate::profile::ProfileManager,
1426        config: &Config,
1427    ) -> TabBarAction {
1428        let mut action = TabBarAction::None;
1429
1430        if !self.show_new_tab_profile_menu {
1431            return action;
1432        }
1433
1434        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
1435            self.show_new_tab_profile_menu = false;
1436            return action;
1437        }
1438
1439        let mut open = true;
1440        egui::Window::new("New Tab")
1441            .collapsible(false)
1442            .resizable(false)
1443            .order(egui::Order::Foreground)
1444            .fixed_size(egui::vec2(200.0, 0.0))
1445            .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-4.0, 4.0))
1446            .open(&mut open)
1447            .show(ctx, |ui| {
1448                // "Default" entry — always first
1449                if ui
1450                    .selectable_label(false, "  Default")
1451                    .on_hover_text("Open a new tab with default settings")
1452                    .clicked()
1453                {
1454                    action = TabBarAction::NewTab;
1455                    self.show_new_tab_profile_menu = false;
1456                }
1457                ui.separator();
1458
1459                // Profile entries in display order
1460                for profile in profiles.profiles_ordered() {
1461                    let icon = profile.icon.as_deref().unwrap_or("  ");
1462                    let label = format!("{} {}", icon, profile.name);
1463                    if ui.selectable_label(false, &label).clicked() {
1464                        action = TabBarAction::NewTabWithProfile(profile.id);
1465                        self.show_new_tab_profile_menu = false;
1466                    }
1467                }
1468
1469                // Assistant panel toggle (only when enabled in config)
1470                if config.ai_inspector_enabled {
1471                    ui.separator();
1472                    if ui
1473                        .selectable_label(false, "  Assistant Panel")
1474                        .on_hover_text("Toggle the AI assistant panel")
1475                        .clicked()
1476                    {
1477                        action = TabBarAction::ToggleAssistantPanel;
1478                        self.show_new_tab_profile_menu = false;
1479                    }
1480                }
1481            });
1482
1483        if !open {
1484            self.show_new_tab_profile_menu = false;
1485        }
1486
1487        action
1488    }
1489
1490    /// Render the context menu for tab options
1491    fn render_context_menu(&mut self, ctx: &egui::Context, tab_id: TabId) -> TabBarAction {
1492        let mut action = TabBarAction::None;
1493        let mut close_menu = false;
1494
1495        // Close on Escape
1496        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
1497            close_menu = true;
1498        }
1499
1500        let area_response = egui::Area::new(egui::Id::new("tab_context_menu"))
1501            .fixed_pos(self.context_menu_pos)
1502            .constrain(true)
1503            .order(egui::Order::Foreground)
1504            .show(ctx, |ui| {
1505                egui::Frame::popup(ui.style())
1506                    .inner_margin(egui::Margin::symmetric(1, 4))
1507                    .show(ui, |ui| {
1508                        ui.set_min_width(160.0);
1509                        ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0);
1510
1511                        // Menu item helper
1512                        let menu_item = |ui: &mut egui::Ui, label: &str| -> bool {
1513                            let response = ui.add_sized(
1514                                [ui.available_width(), 24.0],
1515                                egui::Button::new(label)
1516                                    .frame(false)
1517                                    .fill(egui::Color32::TRANSPARENT),
1518                            );
1519                            response.clicked()
1520                        };
1521
1522                        // Duplicate Tab
1523                        if menu_item(ui, "Duplicate Tab") {
1524                            action = TabBarAction::Duplicate(tab_id);
1525                            close_menu = true;
1526                        }
1527
1528                        // Close Tab
1529                        if menu_item(ui, "Close Tab") {
1530                            action = TabBarAction::Close(tab_id);
1531                            close_menu = true;
1532                        }
1533
1534                        ui.add_space(4.0);
1535                        ui.separator();
1536                        ui.add_space(4.0);
1537
1538                        // Tab Color section
1539                        ui.horizontal(|ui| {
1540                            ui.add_space(8.0);
1541                            ui.label("Tab Color:");
1542                        });
1543
1544                        ui.add_space(4.0);
1545
1546                        // Color presets row
1547                        ui.horizontal(|ui| {
1548                            ui.add_space(8.0);
1549
1550                            let presets: &[([u8; 3], &str)] = &[
1551                                ([220, 50, 50], "Red"),
1552                                ([220, 130, 50], "Orange"),
1553                                ([220, 180, 50], "Yellow"),
1554                                ([50, 180, 50], "Green"),
1555                                ([50, 180, 180], "Cyan"),
1556                                ([50, 100, 220], "Blue"),
1557                                ([180, 50, 180], "Purple"),
1558                            ];
1559
1560                            for (color, name) in presets {
1561                                let btn = ui.add(
1562                                    egui::Button::new("")
1563                                        .fill(egui::Color32::from_rgb(color[0], color[1], color[2]))
1564                                        .min_size(egui::vec2(18.0, 18.0))
1565                                        .corner_radius(2.0),
1566                                );
1567                                if btn.clicked() {
1568                                    action = TabBarAction::SetColor(tab_id, *color);
1569                                    close_menu = true;
1570                                }
1571                                if btn.hovered() {
1572                                    btn.on_hover_text(*name);
1573                                }
1574                            }
1575
1576                            ui.add_space(4.0);
1577
1578                            // Custom color picker
1579                            if ui.color_edit_button_srgb(&mut self.editing_color).changed() {
1580                                action = TabBarAction::SetColor(tab_id, self.editing_color);
1581                            }
1582                        });
1583
1584                        ui.add_space(4.0);
1585
1586                        // Clear color option
1587                        if menu_item(ui, "Clear Color") {
1588                            action = TabBarAction::ClearColor(tab_id);
1589                            close_menu = true;
1590                        }
1591                    });
1592            });
1593
1594        // Close menu if clicked outside (but not on the same frame it was opened)
1595        let current_frame = ctx.cumulative_frame_nr();
1596        if current_frame > self.context_menu_opened_frame
1597            && ctx.input(|i| i.pointer.any_click())
1598            && !area_response.response.hovered()
1599            // Only close if no action was taken (let button clicks register)
1600            && !close_menu
1601            && action == TabBarAction::None
1602        {
1603            close_menu = true;
1604        }
1605
1606        // Close menu if action taken or cancelled
1607        if close_menu {
1608            self.context_menu_tab = None;
1609        }
1610
1611        action
1612    }
1613
1614    /// Get the tab bar height (0 if hidden or if position is Left)
1615    pub fn get_height(&self, tab_count: usize, config: &Config) -> f32 {
1616        if self.should_show(tab_count, config.tab_bar_mode)
1617            && config.tab_bar_position.is_horizontal()
1618        {
1619            config.tab_bar_height
1620        } else {
1621            0.0
1622        }
1623    }
1624
1625    /// Get the tab bar width (non-zero only for Left position, 0 if hidden)
1626    pub fn get_width(&self, tab_count: usize, config: &Config) -> f32 {
1627        if self.should_show(tab_count, config.tab_bar_mode)
1628            && config.tab_bar_position == TabBarPosition::Left
1629        {
1630            config.tab_bar_width
1631        } else {
1632            0.0
1633        }
1634    }
1635
1636    /// Check if the context menu is currently open
1637    pub fn is_context_menu_open(&self) -> bool {
1638        self.context_menu_tab.is_some()
1639    }
1640}
1641
1642fn truncate_plain(title: &str, max_len: usize) -> String {
1643    if max_len == 0 {
1644        return "…".to_string();
1645    }
1646    let mut chars = title.chars();
1647    let mut taken = String::new();
1648    for _ in 0..max_len {
1649        if let Some(c) = chars.next() {
1650            taken.push(c);
1651        } else {
1652            return taken;
1653        }
1654    }
1655    if chars.next().is_some() {
1656        if max_len > 0 {
1657            taken.pop();
1658        }
1659        taken.push('…');
1660    }
1661    taken
1662}
1663
1664fn truncate_segments(segments: &[StyledSegment], max_len: usize) -> Vec<StyledSegment> {
1665    if max_len == 0 {
1666        return vec![StyledSegment {
1667            text: "…".to_string(),
1668            bold: false,
1669            italic: false,
1670            underline: false,
1671            color: None,
1672        }];
1673    }
1674    let mut remaining = max_len;
1675    let mut out: Vec<StyledSegment> = Vec::new();
1676    for seg in segments {
1677        if remaining == 0 {
1678            break;
1679        }
1680        let seg_len = seg.text.chars().count();
1681        if seg_len == 0 {
1682            continue;
1683        }
1684        if seg_len <= remaining {
1685            out.push(seg.clone());
1686            remaining -= seg_len;
1687        } else {
1688            let truncated_text: String =
1689                seg.text.chars().take(remaining.saturating_sub(1)).collect();
1690            let mut truncated = seg.clone();
1691            truncated.text = truncated_text;
1692            truncated.text.push('…');
1693            out.push(truncated);
1694            remaining = 0;
1695        }
1696    }
1697    out
1698}
1699
1700fn render_segments(ui: &mut egui::Ui, segments: &[StyledSegment], fallback_color: egui::Color32) {
1701    ui.horizontal(|ui| {
1702        ui.spacing_mut().item_spacing.x = 0.0;
1703        for segment in segments {
1704            let mut rich = egui::RichText::new(&segment.text);
1705            if segment.bold {
1706                rich = rich.strong();
1707            }
1708            if segment.italic {
1709                rich = rich.italics();
1710            }
1711            if segment.underline {
1712                rich = rich.underline();
1713            }
1714            if let Some(color) = segment.color {
1715                rich = rich.color(egui::Color32::from_rgb(color[0], color[1], color[2]));
1716            } else {
1717                rich = rich.color(fallback_color);
1718            }
1719            ui.label(rich);
1720        }
1721    });
1722}
1723
1724fn estimate_max_chars(_ui: &egui::Ui, font_id: &egui::FontId, available_width: f32) -> usize {
1725    let char_width = (font_id.size * 0.55).max(4.0); // heuristic: ~0.55em per character
1726    ((available_width / char_width).floor() as usize).max(4)
1727}
1728
1729fn parse_html_title(input: &str) -> Vec<StyledSegment> {
1730    let mut segments: Vec<StyledSegment> = Vec::new();
1731    let mut style_stack: Vec<TitleStyle> = vec![TitleStyle {
1732        bold: false,
1733        italic: false,
1734        underline: false,
1735        color: None,
1736    }];
1737    let mut buffer = String::new();
1738    let mut chars = input.chars().peekable();
1739
1740    while let Some(ch) = chars.next() {
1741        if ch == '<' {
1742            // flush buffer
1743            if !buffer.is_empty() {
1744                let style = *style_stack.last().unwrap_or(&TitleStyle {
1745                    bold: false,
1746                    italic: false,
1747                    underline: false,
1748                    color: None,
1749                });
1750                segments.push(StyledSegment {
1751                    text: buffer.clone(),
1752                    bold: style.bold,
1753                    italic: style.italic,
1754                    underline: style.underline,
1755                    color: style.color,
1756                });
1757                buffer.clear();
1758            }
1759
1760            // read tag
1761            let mut tag = String::new();
1762            while let Some(&c) = chars.peek() {
1763                chars.next();
1764                if c == '>' {
1765                    break;
1766                }
1767                tag.push(c);
1768            }
1769
1770            let tag_trimmed = tag.trim().to_lowercase();
1771            match tag_trimmed.as_str() {
1772                "b" => {
1773                    let mut style = *style_stack.last().unwrap();
1774                    style.bold = true;
1775                    style_stack.push(style);
1776                }
1777                "/b" => {
1778                    pop_style(&mut style_stack, |s| s.bold);
1779                }
1780                "i" => {
1781                    let mut style = *style_stack.last().unwrap();
1782                    style.italic = true;
1783                    style_stack.push(style);
1784                }
1785                "/i" => {
1786                    pop_style(&mut style_stack, |s| s.italic);
1787                }
1788                "u" => {
1789                    let mut style = *style_stack.last().unwrap();
1790                    style.underline = true;
1791                    style_stack.push(style);
1792                }
1793                "/u" => {
1794                    pop_style(&mut style_stack, |s| s.underline);
1795                }
1796                t if t.starts_with("span") => {
1797                    if let Some(color) = parse_span_color(&tag_trimmed) {
1798                        let mut style = *style_stack.last().unwrap();
1799                        style.color = Some(color);
1800                        style_stack.push(style);
1801                    } else {
1802                        // unsupported span attributes: ignore tag
1803                    }
1804                }
1805                "/span" => {
1806                    pop_style(&mut style_stack, |s| s.color.is_some());
1807                }
1808                _ => {
1809                    // Unknown or unsupported tag, ignore
1810                }
1811            }
1812        } else {
1813            buffer.push(ch);
1814        }
1815    }
1816
1817    if !buffer.is_empty() {
1818        let style = *style_stack.last().unwrap_or(&TitleStyle {
1819            bold: false,
1820            italic: false,
1821            underline: false,
1822            color: None,
1823        });
1824        segments.push(StyledSegment {
1825            text: buffer,
1826            bold: style.bold,
1827            italic: style.italic,
1828            underline: style.underline,
1829            color: style.color,
1830        });
1831    }
1832
1833    segments
1834}
1835
1836fn pop_style<F>(stack: &mut Vec<TitleStyle>, predicate: F)
1837where
1838    F: Fn(&TitleStyle) -> bool,
1839{
1840    if stack.len() <= 1 {
1841        return;
1842    }
1843    for idx in (1..stack.len()).rev() {
1844        let style = stack[idx];
1845        if predicate(&style) {
1846            stack.remove(idx);
1847            return;
1848        }
1849    }
1850}
1851
1852fn parse_span_color(tag: &str) -> Option<[u8; 3]> {
1853    // expect like: span style="color:#rrggbb" or color:rgb(r,g,b)
1854    let style_attr = tag.split("style=").nth(1)?;
1855    let style_val = style_attr
1856        .trim_start_matches(['\"', '\''])
1857        .trim_end_matches(['\"', '\'']);
1858    let mut color_part = None;
1859    for decl in style_val.split(';') {
1860        let mut kv = decl.splitn(2, ':');
1861        let key = kv.next()?.trim();
1862        let val = kv.next()?.trim();
1863        if key == "color" {
1864            color_part = Some(val);
1865            break;
1866        }
1867    }
1868    let color_str = color_part?;
1869    if let Some(hex) = color_str.strip_prefix('#') {
1870        if hex.len() == 6 {
1871            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1872            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1873            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1874            return Some([r, g, b]);
1875        }
1876    } else if let Some(rgb) = color_str
1877        .strip_prefix("rgb(")
1878        .and_then(|s| s.strip_suffix(')'))
1879    {
1880        let parts: Vec<&str> = rgb.split(',').map(|p| p.trim()).collect();
1881        if parts.len() == 3 {
1882            let r = parts[0].parse::<u8>().ok()?;
1883            let g = parts[1].parse::<u8>().ok()?;
1884            let b = parts[2].parse::<u8>().ok()?;
1885            return Some([r, g, b]);
1886        }
1887    }
1888    None
1889}
1890
1891impl Default for TabBarUI {
1892    fn default() -> Self {
1893        Self::new()
1894    }
1895}
1896
1897#[cfg(test)]
1898mod tests {
1899    use super::*;
1900
1901    #[test]
1902    fn parse_html_title_basic_tags() {
1903        let segments = parse_html_title("<b>Hello</b> <i>world</i>");
1904        assert_eq!(
1905            segments,
1906            vec![
1907                StyledSegment {
1908                    text: "Hello".to_string(),
1909                    bold: true,
1910                    italic: false,
1911                    underline: false,
1912                    color: None
1913                },
1914                StyledSegment {
1915                    text: " ".to_string(),
1916                    bold: false,
1917                    italic: false,
1918                    underline: false,
1919                    color: None
1920                },
1921                StyledSegment {
1922                    text: "world".to_string(),
1923                    bold: false,
1924                    italic: true,
1925                    underline: false,
1926                    color: None
1927                }
1928            ]
1929        );
1930    }
1931
1932    #[test]
1933    fn parse_html_title_span_color() {
1934        let segments = parse_html_title("<span style=\"color:#ff0000\">Red</span> text");
1935        assert_eq!(segments.len(), 2);
1936        assert_eq!(
1937            segments[0],
1938            StyledSegment {
1939                text: "Red".to_string(),
1940                bold: false,
1941                italic: false,
1942                underline: false,
1943                color: Some([255, 0, 0])
1944            }
1945        );
1946    }
1947
1948    #[test]
1949    fn truncate_segments_adds_ellipsis() {
1950        let segs = vec![StyledSegment {
1951            text: "HelloWorld".to_string(),
1952            bold: false,
1953            italic: false,
1954            underline: false,
1955            color: None,
1956        }];
1957        let truncated = truncate_segments(&segs, 6);
1958        assert_eq!(truncated[0].text, "Hello…");
1959    }
1960
1961    #[test]
1962    fn truncate_plain_handles_short_text() {
1963        assert_eq!(truncate_plain("abc", 5), "abc");
1964        assert_eq!(truncate_plain("abcdef", 5), "abcd…");
1965    }
1966}