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};
6use crate::tab::{TabId, TabManager};
7
8/// Actions that can be triggered from the tab bar
9#[derive(Debug, Clone, PartialEq)]
10pub enum TabBarAction {
11    /// No action
12    None,
13    /// Switch to a specific tab
14    SwitchTo(TabId),
15    /// Close a specific tab
16    Close(TabId),
17    /// Create a new tab
18    NewTab,
19    /// Reorder a tab to a new position
20    #[allow(dead_code)]
21    Reorder(TabId, usize),
22}
23
24/// Tab bar UI state
25pub struct TabBarUI {
26    /// Currently hovered tab ID
27    pub hovered_tab: Option<TabId>,
28    /// Tab where close button is hovered
29    pub close_hovered: Option<TabId>,
30    /// Whether a drag is in progress
31    #[allow(dead_code)]
32    drag_in_progress: bool,
33    /// Tab being dragged
34    #[allow(dead_code)]
35    dragging_tab: Option<TabId>,
36}
37
38impl TabBarUI {
39    /// Create a new tab bar UI
40    pub fn new() -> Self {
41        Self {
42            hovered_tab: None,
43            close_hovered: None,
44            drag_in_progress: false,
45            dragging_tab: None,
46        }
47    }
48
49    /// Check if tab bar should be visible
50    pub fn should_show(&self, tab_count: usize, mode: TabBarMode) -> bool {
51        match mode {
52            TabBarMode::Always => true,
53            TabBarMode::WhenMultiple => tab_count > 1,
54            TabBarMode::Never => false,
55        }
56    }
57
58    /// Render the tab bar and return any action triggered
59    pub fn render(
60        &mut self,
61        ctx: &egui::Context,
62        tabs: &TabManager,
63        config: &Config,
64    ) -> TabBarAction {
65        let tab_count = tabs.tab_count();
66
67        // Don't show if configured to hide
68        if !self.should_show(tab_count, config.tab_bar_mode) {
69            return TabBarAction::None;
70        }
71
72        let mut action = TabBarAction::None;
73        let active_tab_id = tabs.active_tab_id();
74
75        // Tab bar area at the top
76        egui::TopBottomPanel::top("tab_bar")
77            .exact_height(config.tab_bar_height)
78            .frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(40, 40, 40)))
79            .show(ctx, |ui| {
80                ui.horizontal(|ui| {
81                    // Style for tabs
82                    ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
83
84                    // Render each tab
85                    for tab in tabs.tabs() {
86                        let is_active = Some(tab.id) == active_tab_id;
87                        let is_bell_active = tab.is_bell_active();
88                        let tab_action = self.render_tab(
89                            ui,
90                            tab.id,
91                            &tab.title,
92                            is_active,
93                            tab.has_activity,
94                            is_bell_active,
95                            config,
96                        );
97
98                        if tab_action != TabBarAction::None {
99                            action = tab_action;
100                        }
101                    }
102
103                    // New tab button
104                    ui.add_space(4.0);
105                    let new_tab_btn = ui.add(
106                        egui::Button::new("+")
107                            .min_size(egui::vec2(24.0, config.tab_bar_height - 4.0))
108                            .fill(egui::Color32::TRANSPARENT),
109                    );
110
111                    if new_tab_btn.clicked() {
112                        action = TabBarAction::NewTab;
113                    }
114
115                    if new_tab_btn.hovered() {
116                        new_tab_btn.on_hover_text("New Tab (Cmd+T)");
117                    }
118                });
119            });
120
121        action
122    }
123
124    /// Render a single tab and return any action triggered
125    #[allow(clippy::too_many_arguments)]
126    fn render_tab(
127        &mut self,
128        ui: &mut egui::Ui,
129        id: TabId,
130        title: &str,
131        is_active: bool,
132        has_activity: bool,
133        is_bell_active: bool,
134        config: &Config,
135    ) -> TabBarAction {
136        let mut action = TabBarAction::None;
137
138        // Calculate tab width (min 80px, max 200px)
139        let available_width = ui.available_width();
140        let tab_count = 10; // estimate
141        let tab_width = (available_width / tab_count as f32).clamp(80.0, 200.0);
142
143        // Tab background color
144        let bg_color = if is_active {
145            egui::Color32::from_rgb(60, 60, 60)
146        } else if self.hovered_tab == Some(id) {
147            egui::Color32::from_rgb(50, 50, 50)
148        } else {
149            egui::Color32::from_rgb(40, 40, 40)
150        };
151
152        // Tab frame - use allocate_ui_with_layout to get a proper interactive response
153        let (tab_rect, tab_response) = ui.allocate_exact_size(
154            egui::vec2(tab_width, config.tab_bar_height),
155            egui::Sense::click(),
156        );
157
158        // Draw tab background
159        if ui.is_rect_visible(tab_rect) {
160            ui.painter().rect_filled(tab_rect, 0.0, bg_color);
161
162            // Create a child UI for the tab content
163            let mut content_ui = ui.new_child(
164                egui::UiBuilder::new()
165                    .max_rect(tab_rect.shrink2(egui::vec2(8.0, 4.0)))
166                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
167            );
168
169            content_ui.horizontal(|ui| {
170                // Bell indicator (takes priority over activity indicator)
171                if is_bell_active {
172                    ui.colored_label(egui::Color32::from_rgb(255, 200, 100), "🔔");
173                    ui.add_space(4.0);
174                } else if has_activity && !is_active {
175                    // Activity indicator
176                    ui.colored_label(egui::Color32::from_rgb(100, 180, 255), "•");
177                    ui.add_space(4.0);
178                }
179
180                // Tab index if configured
181                if config.tab_show_index {
182                    // We'd need to get the index, skip for now
183                }
184
185                // Title (truncated)
186                let max_title_len = if config.tab_show_close_button { 15 } else { 20 };
187                let display_title = if title.len() > max_title_len {
188                    format!("{}…", &title[..max_title_len - 1])
189                } else {
190                    title.to_string()
191                };
192
193                let text_color = if is_active {
194                    egui::Color32::WHITE
195                } else {
196                    egui::Color32::from_rgb(180, 180, 180)
197                };
198
199                ui.label(egui::RichText::new(&display_title).color(text_color));
200
201                // Spacer to push close button to the right
202                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
203                    // Close button
204                    if config.tab_show_close_button {
205                        let close_color = if self.close_hovered == Some(id) {
206                            egui::Color32::from_rgb(255, 100, 100)
207                        } else {
208                            egui::Color32::from_rgb(150, 150, 150)
209                        };
210
211                        let close_btn = ui.add(
212                            egui::Button::new(
213                                egui::RichText::new("×").color(close_color).size(14.0),
214                            )
215                            .fill(egui::Color32::TRANSPARENT)
216                            .frame(false),
217                        );
218
219                        if close_btn.hovered() {
220                            self.close_hovered = Some(id);
221                        } else if self.close_hovered == Some(id) {
222                            self.close_hovered = None;
223                        }
224
225                        if close_btn.clicked() {
226                            action = TabBarAction::Close(id);
227                        }
228                    }
229                });
230            });
231        }
232
233        // Handle tab click (switch to tab)
234        if tab_response.clicked() && action == TabBarAction::None {
235            action = TabBarAction::SwitchTo(id);
236        }
237
238        // Update hover state
239        if tab_response.hovered() {
240            self.hovered_tab = Some(id);
241        } else if self.hovered_tab == Some(id) {
242            self.hovered_tab = None;
243        }
244
245        // Active tab indicator (bottom border)
246        if is_active {
247            ui.painter().hline(
248                tab_rect.left()..=tab_rect.right(),
249                tab_rect.bottom() - 2.0,
250                egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255)),
251            );
252        }
253
254        action
255    }
256
257    /// Get the tab bar height (0 if hidden)
258    #[allow(dead_code)]
259    pub fn get_height(&self, tab_count: usize, config: &Config) -> f32 {
260        if self.should_show(tab_count, config.tab_bar_mode) {
261            config.tab_bar_height
262        } else {
263            0.0
264        }
265    }
266}
267
268impl Default for TabBarUI {
269    fn default() -> Self {
270        Self::new()
271    }
272}