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