Skip to main content

par_term/tab_bar_ui/
mod.rs

1//! Tab bar UI using egui
2//!
3//! Provides a visual tab bar for switching between terminal tabs.
4//!
5//! ## Module layout
6//!
7//! - `state`: `TabBarUI` struct definition and constructor.
8//! - `horizontal`: Horizontal layout rendering (`render_horizontal`).
9//! - `context_menu`: Right-click context menu (rename, color, icon, duplicate, close).
10//! - `drag_drop`: Drag-and-drop state and rendering for tab reordering.
11//! - `profile_menu`: Profile selection popup for the new-tab chevron button.
12//! - `tab_rendering`: Vertical tab rendering and shared params/helpers.
13//! - `tab_painter`: Horizontal per-tab painting (`render_tab_with_width`).
14//! - `title_utils`: HTML title parsing, emoji sanitization, and styled segment rendering.
15
16mod context_menu;
17mod drag_drop;
18mod horizontal;
19mod profile_menu;
20mod state;
21mod tab_painter;
22mod tab_rendering;
23mod title_utils;
24
25// Re-export TabBarUI so external callers are unaffected.
26pub use state::TabBarUI;
27
28use crate::config::{Config, TabBarMode, TabBarPosition};
29use crate::tab::{TabId, TabManager};
30use crate::ui_constants::{TAB_DRAW_SHRINK_Y, TAB_SPACING};
31use tab_rendering::TabRenderParams;
32
33/// Width reserved for the profile chevron (▾) button in the tab bar split button.
34/// Accounts for the button min_size (14px) plus egui button frame padding (~4px each side).
35pub(super) const CHEVRON_RESERVED: f32 = 28.0;
36
37/// Actions that can be triggered from the tab bar
38#[derive(Debug, Clone, PartialEq)]
39pub enum TabBarAction {
40    /// No action
41    None,
42    /// Switch to a specific tab
43    SwitchTo(TabId),
44    /// Close a specific tab
45    Close(TabId),
46    /// Create a new tab
47    NewTab,
48    /// Create a new tab from a specific profile
49    NewTabWithProfile(crate::profile::ProfileId),
50    /// Reorder a tab to a new position
51    Reorder(TabId, usize),
52    /// Set custom color for a tab
53    SetColor(TabId, [u8; 3]),
54    /// Clear custom color for a tab (revert to default)
55    ClearColor(TabId),
56    /// Duplicate a specific tab
57    Duplicate(TabId),
58    /// Rename a specific tab
59    RenameTab(TabId, String),
60    /// Set custom icon for a tab (None = clear)
61    SetTabIcon(TabId, Option<String>),
62    /// Toggle the AI assistant panel
63    ToggleAssistantPanel,
64    /// Move a tab to a brand-new par-term window.
65    MoveTabToNewWindow(TabId),
66    /// Move a tab into an existing par-term window.
67    MoveTabToExistingWindow(TabId, winit::window::WindowId),
68    /// Promote the focused pane of this tab to a new tab
69    PromotePaneToTab(TabId),
70    /// Start demote pick mode for this tab
71    DemoteTabToPane(TabId),
72}
73
74impl TabBarUI {
75    /// Check if tab bar should be visible
76    pub fn should_show(&self, tab_count: usize, mode: TabBarMode) -> bool {
77        match mode {
78            TabBarMode::Always => true,
79            TabBarMode::WhenMultiple => tab_count > 1,
80            TabBarMode::Never => false,
81        }
82    }
83
84    /// Check if a drag operation is in progress
85    pub fn is_dragging(&self) -> bool {
86        self.drag_in_progress
87    }
88
89    /// Render the tab bar and return any action triggered
90    pub fn render(
91        &mut self,
92        ctx: &mut egui::Ui,
93        tabs: &TabManager,
94        config: &Config,
95        profiles: &crate::profile::ProfileManager,
96        right_reserved_width: f32,
97    ) -> TabBarAction {
98        let tab_count = tabs.visible_tab_count();
99
100        // Don't show if configured to hide
101        if !self.should_show(tab_count, config.tabs.tab_bar_mode) {
102            // The in-app menu is drawn inside this bar, so hiding the bar makes
103            // the menu unreachable — including from a `toggle_menu` keybinding,
104            // which only takes effect where the menu is drawn. Users who hide
105            // the tab bar need keybindings for the commands themselves.
106            self.app_menu.hide(ctx.ctx());
107            return TabBarAction::None;
108        }
109
110        match config.tabs.tab_bar_position {
111            TabBarPosition::Left => self.render_vertical(ctx, tabs, config, profiles),
112            _ => self.render_horizontal(ctx, tabs, config, profiles, right_reserved_width),
113        }
114    }
115
116    /// Render the tab bar in vertical layout (left side panel)
117    fn render_vertical(
118        &mut self,
119        ctx: &mut egui::Ui,
120        tabs: &TabManager,
121        config: &Config,
122        profiles: &crate::profile::ProfileManager,
123    ) -> TabBarAction {
124        let tab_count = tabs.visible_tab_count();
125        let visible_tabs = tabs.visible_tabs();
126
127        self.tab_rects.clear();
128
129        let mut action = TabBarAction::None;
130        let active_tab_id = tabs.active_tab_id();
131
132        let bar_bg = config.tab_colors.tab_bar_background;
133        let tab_spacing = TAB_SPACING;
134        let tab_height = config.tabs.tab_bar_height; // Reuse height config for per-tab row height
135
136        egui::Panel::left("tab_bar")
137            .exact_size(config.tabs.tab_bar_width)
138            .frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(bar_bg[0], bar_bg[1], bar_bg[2])))
139            .show(ctx, |ui| {
140                egui::ScrollArea::vertical()
141                    .scroll_bar_visibility(
142                        egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
143                    )
144                    .show(ui, |ui| {
145                        ui.vertical(|ui| {
146                            ui.spacing_mut().item_spacing = egui::vec2(0.0, tab_spacing);
147
148                            // In-app menu — top of the panel, above the new-tab button
149                            if crate::menu::AppMenuUi::enabled() {
150                                self.app_menu.show(
151                                    ui,
152                                    profiles,
153                                    tab_height - TAB_DRAW_SHRINK_Y * 2.0,
154                                );
155                            }
156
157                            // New tab split button — rendered first (top of the panel)
158                            ui.horizontal(|ui| {
159                                // Zero spacing between + and ▾
160                                ui.spacing_mut().item_spacing.x = 0.0;
161
162                                let show_chevron_v = !profiles.is_empty()
163                                    || config.ai_inspector.ai_inspector_enabled;
164                                let chevron_space = if show_chevron_v {
165                                    CHEVRON_RESERVED
166                                } else {
167                                    0.0
168                                };
169                                let plus_btn = ui.add(
170                                    egui::Button::new("+")
171                                        .min_size(egui::vec2(
172                                            ui.available_width() - chevron_space,
173                                            tab_height - TAB_DRAW_SHRINK_Y * 2.0,
174                                        ))
175                                        .fill(egui::Color32::TRANSPARENT),
176                                );
177                                if plus_btn.clicked_by(egui::PointerButton::Primary) {
178                                    action = TabBarAction::NewTab;
179                                }
180                                if plus_btn.hovered() {
181                                    #[cfg(target_os = "macos")]
182                                    plus_btn.on_hover_text("New Tab (Cmd+T)");
183                                    #[cfg(not(target_os = "macos"))]
184                                    plus_btn.on_hover_text("New Tab (Ctrl+Shift+T)");
185                                }
186
187                                if show_chevron_v {
188                                    let chevron_btn = ui.add(
189                                        egui::Button::new("⏷")
190                                            .min_size(egui::vec2(
191                                                CHEVRON_RESERVED / 2.0,
192                                                tab_height - TAB_DRAW_SHRINK_Y * 2.0,
193                                            ))
194                                            .fill(egui::Color32::TRANSPARENT),
195                                    );
196                                    if chevron_btn.clicked_by(egui::PointerButton::Primary) {
197                                        self.show_new_tab_profile_menu =
198                                            !self.show_new_tab_profile_menu;
199                                    }
200                                    if chevron_btn.hovered() {
201                                        chevron_btn.on_hover_text("New tab from profile");
202                                    }
203                                }
204                            });
205
206                            for (index, tab) in visible_tabs.iter().enumerate() {
207                                let is_active = Some(tab.id) == active_tab_id;
208                                let is_bell_active = tab.is_bell_active();
209                                let (tab_action, tab_rect) = self.render_vertical_tab(
210                                    ui,
211                                    TabRenderParams {
212                                        id: tab.id,
213                                        index,
214                                        title: &tab.title,
215                                        profile_icon: tab
216                                            .custom_icon
217                                            .as_deref()
218                                            .or(tab.profile.profile_icon.as_deref()),
219                                        custom_icon: tab.custom_icon.as_deref(),
220                                        is_active,
221                                        has_activity: tab.activity.has_activity,
222                                        is_bell_active,
223                                        custom_color: tab.custom_color,
224                                        config,
225                                        tab_size: tab_height,
226                                        tab_count,
227                                    },
228                                );
229                                self.tab_rects.push((tab.id, tab_rect));
230
231                                if tab_action != TabBarAction::None {
232                                    action = tab_action;
233                                }
234                            }
235                        });
236                    });
237
238                // Handle drag feedback for vertical mode
239                if self.drag_in_progress {
240                    let drag_action = self.render_vertical_drag_feedback(ui, config);
241                    if drag_action != TabBarAction::None {
242                        action = drag_action;
243                    }
244                }
245            });
246
247        // Render floating ghost tab during drag
248        if self.drag_in_progress && self.dragging_tab.is_some() {
249            self.render_ghost_tab(ctx, config);
250        }
251
252        // Handle context menu
253        if let Some(context_tab_id) = self.context_menu_tab {
254            let menu_action = self.render_context_menu(ctx, context_tab_id);
255            if menu_action != TabBarAction::None {
256                action = menu_action;
257            }
258        }
259
260        // Render new-tab profile menu if open
261        let menu_action = self.render_new_tab_profile_menu(ctx, profiles, config);
262        if menu_action != TabBarAction::None {
263            action = menu_action;
264        }
265
266        action
267    }
268
269    /// Get the tab bar height (0 if hidden or if position is Left)
270    pub fn get_height(&self, tab_count: usize, config: &Config) -> f32 {
271        if self.should_show(tab_count, config.tabs.tab_bar_mode)
272            && config.tabs.tab_bar_position.is_horizontal()
273        {
274            config.tabs.tab_bar_height
275        } else {
276            0.0
277        }
278    }
279
280    /// Get the tab bar width (non-zero only for Left position, 0 if hidden)
281    pub fn get_width(&self, tab_count: usize, config: &Config) -> f32 {
282        if self.should_show(tab_count, config.tabs.tab_bar_mode)
283            && config.tabs.tab_bar_position == TabBarPosition::Left
284        {
285            config.tabs.tab_bar_width
286        } else {
287            0.0
288        }
289    }
290
291    /// Check if the context menu is currently open
292    pub fn is_context_menu_open(&self) -> bool {
293        self.context_menu_tab.is_some()
294    }
295
296    /// Check if the in-app menu's drop-down is open.
297    ///
298    /// True only on platforms that draw it (Linux/BSD, or a forced-on run —
299    /// see [`crate::menu::AppMenuUi::enabled`]). While it is open, keyboard
300    /// input should be routed to egui rather than the terminal.
301    pub fn is_app_menu_open(&self) -> bool {
302        self.app_menu.is_open()
303    }
304
305    /// Return the tab ID at the given egui logical-pixel position, using the
306    /// tab rects cached from the last render frame.  Returns `None` if the
307    /// position doesn't fall inside any rendered tab.
308    pub fn tab_at_logical_pos(&self, pos: egui::Pos2) -> Option<TabId> {
309        for (id, rect) in &self.tab_rects {
310            if rect.contains(pos) {
311                return Some(*id);
312            }
313        }
314        None
315    }
316
317    /// Check if the tab rename text field is active
318    pub fn is_renaming(&self) -> bool {
319        self.renaming_tab && self.context_menu_tab.is_some()
320    }
321
322    /// Calculate the drop target insert index for a horizontal drag given a pointer x position.
323    ///
324    /// Returns `None` if the drop would be a no-op (same position as source), or
325    /// `Some(insert_index)` for a valid insertion point.
326    ///
327    /// This is a pure helper that can be tested without egui rendering.
328    pub fn calculate_drop_target_horizontal(
329        tab_rects: &[(TabId, egui::Rect)],
330        drag_source_index: Option<usize>,
331        pointer_x: f32,
332    ) -> Option<usize> {
333        let mut insert_index = tab_rects.len();
334        for (i, (_id, rect)) in tab_rects.iter().enumerate() {
335            if pointer_x < rect.center().x {
336                insert_index = i;
337                break;
338            }
339        }
340        let is_noop =
341            drag_source_index.is_some_and(|src| insert_index == src || insert_index == src + 1);
342        if is_noop { None } else { Some(insert_index) }
343    }
344
345    /// Convert an insertion index to an effective target index, accounting for source removal.
346    ///
347    /// When a tab is removed from `source_index` and re-inserted at `insert_index`, indices
348    /// after the source shift down by one.  This helper applies that adjustment.
349    pub fn insertion_to_target_index(
350        insert_index: usize,
351        drag_source_index: Option<usize>,
352    ) -> usize {
353        if let Some(src) = drag_source_index {
354            if insert_index > src {
355                insert_index - 1
356            } else {
357                insert_index
358            }
359        } else {
360            insert_index
361        }
362    }
363
364    /// Calculate the x origin for horizontally scrolled tab content.
365    ///
366    /// A larger scroll offset reveals tabs further to the right, so the content
367    /// itself must move left inside the clipped tab area.
368    pub fn horizontal_tab_content_origin_x(tab_area_left: f32, scroll_offset: f32) -> f32 {
369        tab_area_left - scroll_offset.max(0.0)
370    }
371
372    /// Set drag state directly; used by integration tests to exercise state transitions
373    /// without requiring a live egui render loop.
374    pub fn test_set_drag_state(&mut self, tab_id: Option<TabId>, in_progress: bool) {
375        self.drag_in_progress = in_progress;
376        self.dragging_tab = tab_id;
377    }
378
379    /// Set the drop target index directly; used by integration tests.
380    pub fn test_set_drop_target(&mut self, index: Option<usize>) {
381        self.drop_target_index = index;
382    }
383
384    /// Get the current drop target index; used by integration tests.
385    pub fn test_drop_target_index(&self) -> Option<usize> {
386        self.drop_target_index
387    }
388
389    /// Get the id of the tab currently being dragged; used by integration tests.
390    pub fn test_dragging_tab(&self) -> Option<TabId> {
391        self.dragging_tab
392    }
393
394    /// Open the context menu for a specific tab; used by integration tests.
395    pub fn test_open_context_menu(&mut self, tab_id: TabId) {
396        self.context_menu_tab = Some(tab_id);
397        self.context_menu_opened_frame = 0;
398        self.renaming_tab = false;
399        self.picking_icon = false;
400    }
401
402    /// Close the context menu; used by integration tests.
403    pub fn test_close_context_menu(&mut self) {
404        self.context_menu_tab = None;
405        self.renaming_tab = false;
406        self.picking_icon = false;
407    }
408
409    /// Get the context menu tab id; used by integration tests.
410    pub fn test_context_menu_tab(&self) -> Option<TabId> {
411        self.context_menu_tab
412    }
413
414    /// Get the tab ID for which the context menu is currently open, if any.
415    pub fn context_menu_tab_id(&self) -> Option<TabId> {
416        self.context_menu_tab
417    }
418
419    /// Set rename mode active/inactive; used by integration tests.
420    pub fn test_set_renaming(&mut self, value: bool) {
421        self.renaming_tab = value;
422    }
423
424    /// Set cached tab rects directly; used by integration tests to exercise
425    /// `tab_at_logical_pos` without requiring a live egui render pass.
426    pub fn test_set_tab_rects(&mut self, rects: Vec<(TabId, egui::Rect)>) {
427        self.tab_rects = rects;
428    }
429
430    /// Set horizontal scroll state directly for integration tests.
431    pub fn test_set_horizontal_scroll_state(&mut self, needs_scroll: bool, scroll_offset: f32) {
432        self.needs_horizontal_scroll = needs_scroll;
433        self.scroll_offset = scroll_offset;
434    }
435
436    /// Get the current horizontal scroll offset for integration tests.
437    pub fn test_scroll_offset(&self) -> f32 {
438        self.scroll_offset
439    }
440
441    /// Update the move-tab context shown in the right-click context menu.
442    /// Must be called each frame *before* `render()` so the context menu has
443    /// fresh state.
444    pub fn set_move_tab_context(
445        &mut self,
446        gateway_active: bool,
447        tab_count: usize,
448        candidates: Vec<(winit::window::WindowId, String)>,
449        context_tab_has_multiple_panes: bool,
450    ) {
451        self.move_gateway_active = gateway_active;
452        self.move_source_tab_count = tab_count;
453        self.move_candidates = candidates;
454        self.tab_has_multiple_panes = context_tab_has_multiple_panes;
455    }
456}