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}