Skip to main content

par_term/
profile_drawer_ui.rs

1//! Profile drawer UI using egui
2//!
3//! Provides a collapsible right-side drawer for quick profile access.
4
5use crate::config::Config;
6use crate::profile::{ProfileId, ProfileManager};
7use crate::ui_constants::{PROFILE_DRAWER_MAX_WIDTH, PROFILE_DRAWER_MIN_WIDTH};
8
9/// Actions that can be triggered from the profile drawer
10#[derive(Debug, Clone, PartialEq)]
11pub enum ProfileDrawerAction {
12    /// No action
13    None,
14    /// Open a profile (create new tab from profile)
15    OpenProfile(ProfileId),
16    /// Open the profile management modal
17    ManageProfiles,
18}
19
20/// Profile drawer UI state
21pub struct ProfileDrawerUI {
22    /// Whether the drawer is expanded (visible)
23    pub expanded: bool,
24    /// Currently selected profile ID
25    pub selected: Option<ProfileId>,
26    /// Currently hovered profile ID
27    pub hovered: Option<ProfileId>,
28    /// Drawer width in pixels
29    pub width: f32,
30    /// Tag filter text (for searching/filtering profiles by tags)
31    pub tag_filter: String,
32}
33
34impl ProfileDrawerUI {
35    /// Default drawer width
36    const DEFAULT_WIDTH: f32 = 220.0;
37    /// Collapsed tab width (the toggle button)
38    const COLLAPSED_WIDTH: f32 = 12.0;
39    /// Toggle button height
40    const BUTTON_HEIGHT: f32 = 30.0;
41
42    /// Create a new profile drawer UI (collapsed by default)
43    pub fn new() -> Self {
44        Self {
45            expanded: false,
46            selected: None,
47            hovered: None,
48            width: Self::DEFAULT_WIDTH,
49            tag_filter: String::new(),
50        }
51    }
52
53    /// Calculate the toggle button rectangle given the window size
54    pub fn get_toggle_button_rect(
55        &self,
56        window_width: f32,
57        window_height: f32,
58    ) -> (f32, f32, f32, f32) {
59        // When expanded, button is at left edge of drawer; when collapsed, at right edge of window
60        let x = if self.expanded {
61            window_width - self.width - Self::COLLAPSED_WIDTH - 2.0
62        } else {
63            window_width - Self::COLLAPSED_WIDTH - 2.0
64        };
65        let y = (window_height - Self::BUTTON_HEIGHT) / 2.0;
66        (x, y, Self::COLLAPSED_WIDTH, Self::BUTTON_HEIGHT)
67    }
68
69    /// Check if a point (in window coordinates) is inside the toggle button
70    pub fn is_point_in_toggle_button(
71        &self,
72        px: f32,
73        py: f32,
74        window_width: f32,
75        window_height: f32,
76    ) -> bool {
77        let (x, y, w, h) = self.get_toggle_button_rect(window_width, window_height);
78        px >= x && px <= x + w && py >= y && py <= y + h
79    }
80
81    /// Toggle drawer expanded state
82    pub fn toggle(&mut self) {
83        self.expanded = !self.expanded;
84        log::info!(
85            "Profile drawer toggled: {}",
86            if self.expanded {
87                "expanded"
88            } else {
89                "collapsed"
90            }
91        );
92    }
93
94    /// Render the profile drawer and return any action triggered.
95    ///
96    /// `bottom_margin` should be set to the height of any floating status bar
97    /// (e.g. the custom status bar rendered as an `egui::Area`) so the side
98    /// panel stops above it rather than extending behind it.
99    pub fn render(
100        &mut self,
101        ctx: &mut egui::Ui,
102        profile_manager: &ProfileManager,
103        config: &Config,
104        modal_visible: bool,
105        bottom_margin: f32,
106    ) -> ProfileDrawerAction {
107        let mut action = ProfileDrawerAction::None;
108        let mut toggle_clicked = false;
109
110        // Reserve space for any floating status bar rendered via egui::Area.
111        // egui::Area does not participate in the panel layout, so without this
112        // spacer the SidePanel would extend behind the status bar.
113        if bottom_margin > 0.0 {
114            egui::Panel::bottom("profile_drawer_bottom_margin")
115                .exact_size(bottom_margin)
116                .frame(egui::Frame::NONE)
117                .show(ctx, |_ui| {});
118        }
119
120        // Render the side panel FIRST if expanded, so we get the current width
121        // This ensures the toggle button position is accurate during resize
122        let panel_rect = if self.expanded {
123            let response = egui::Panel::right("profile_drawer")
124                .resizable(true)
125                .default_size(self.width)
126                .min_size(PROFILE_DRAWER_MIN_WIDTH)
127                .max_size(PROFILE_DRAWER_MAX_WIDTH)
128                .frame(
129                    egui::Frame::side_top_panel(&ctx.global_style())
130                        .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 245))
131                        .inner_margin(egui::Margin::same(8)),
132                )
133                .show(ctx, |ui| {
134                    self.render_panel_contents(ui, profile_manager, &mut action);
135                });
136
137            // Update width from the panel's actual rect
138            self.width = response.response.rect.width();
139            Some(response.response.rect)
140        } else {
141            None
142        };
143
144        // Calculate toggle button position using the actual panel rect
145        let button_width = Self::COLLAPSED_WIDTH;
146        let button_height = Self::BUTTON_HEIGHT;
147        let viewport_rect = ctx.input(|i| i.viewport_rect());
148
149        let button_x = if let Some(rect) = panel_rect {
150            // Position at left edge of the actual panel rect
151            rect.left() - button_width - 2.0
152        } else {
153            // Collapsed: position at right edge of window
154            viewport_rect.right() - button_width - 2.0
155        };
156
157        let button_rect = egui::Rect::from_min_size(
158            egui::pos2(button_x, viewport_rect.center().y - button_height / 2.0),
159            egui::vec2(button_width, button_height),
160        );
161
162        // Render toggle button (skip if modal is open to avoid z-order issues,
163        // or if profile drawer button is disabled in config)
164        if !modal_visible && config.tabs.show_profile_drawer_button {
165            egui::Area::new(egui::Id::new("profile_drawer_toggle_area"))
166                .fixed_pos(button_rect.min)
167                .order(egui::Order::Foreground)
168                .show(ctx, |ui| {
169                    let response = ui.allocate_response(button_rect.size(), egui::Sense::click());
170
171                    let bg_color = if response.hovered() {
172                        egui::Color32::from_rgba_unmultiplied(60, 60, 60, 220)
173                    } else {
174                        egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200)
175                    };
176
177                    ui.painter().rect_filled(response.rect, 4.0, bg_color);
178
179                    let arrow = if self.expanded { "▶" } else { "◀" };
180                    ui.painter().text(
181                        response.rect.center(),
182                        egui::Align2::CENTER_CENTER,
183                        arrow,
184                        egui::FontId::proportional(7.0),
185                        egui::Color32::WHITE,
186                    );
187
188                    // Use clicked_by to only respond to mouse clicks, not keyboard Enter/Space
189                    // This prevents Enter key in the terminal from toggling the drawer
190                    if response.clicked_by(egui::PointerButton::Primary) {
191                        toggle_clicked = true;
192                    }
193                });
194
195            if toggle_clicked {
196                self.toggle();
197                ctx.request_repaint();
198            }
199        }
200
201        action
202    }
203
204    /// Render the panel contents (extracted to allow panel-first rendering)
205    fn render_panel_contents(
206        &mut self,
207        ui: &mut egui::Ui,
208        profile_manager: &ProfileManager,
209        action: &mut ProfileDrawerAction,
210    ) {
211        // Header
212        ui.horizontal(|ui| {
213            ui.heading("Profiles");
214            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
215                if ui.small_button("Manage").clicked() {
216                    *action = ProfileDrawerAction::ManageProfiles;
217                }
218            });
219        });
220
221        // Tag filter (search box)
222        ui.horizontal(|ui| {
223            ui.label("🔍");
224            let response = ui.add(
225                egui::TextEdit::singleline(&mut self.tag_filter)
226                    .hint_text("Filter by tag or name...")
227                    .desired_width(ui.available_width() - 20.0),
228            );
229            if response.changed() {
230                // Clear selection when filter changes
231                self.selected = None;
232            }
233        });
234        ui.separator();
235
236        // Profile list
237        crate::debug_info!(
238            "PROFILE",
239            "Profile drawer render: {} profiles",
240            profile_manager.len()
241        );
242        if profile_manager.is_empty() {
243            ui.vertical_centered(|ui| {
244                ui.add_space(20.0);
245                ui.label(
246                    egui::RichText::new("No profiles")
247                        .italics()
248                        .color(egui::Color32::GRAY),
249                );
250                ui.add_space(10.0);
251                if ui.button("Create Profile").clicked() {
252                    *action = ProfileDrawerAction::ManageProfiles;
253                }
254            });
255        } else {
256            // Get filtered profiles
257            let filtered_profiles = profile_manager.filter_by_tags(&self.tag_filter);
258
259            // Reserve space for the action buttons at the bottom
260            let available = ui.available_height();
261            let button_area_height = 40.0;
262            let scroll_height = (available - button_area_height).max(100.0);
263
264            if filtered_profiles.is_empty() {
265                ui.vertical_centered(|ui| {
266                    ui.add_space(20.0);
267                    ui.label(
268                        egui::RichText::new("No matching profiles")
269                            .italics()
270                            .color(egui::Color32::GRAY),
271                    );
272                    ui.add_space(10.0);
273                    if ui.small_button("Clear filter").clicked() {
274                        self.tag_filter.clear();
275                    }
276                });
277            } else {
278                // Scrollable profile list
279                egui::ScrollArea::vertical()
280                    .max_height(scroll_height)
281                    .auto_shrink([false, false])
282                    .show(ui, |ui| {
283                        for profile in filtered_profiles {
284                            let is_selected = self.selected == Some(profile.id);
285                            let is_dynamic = profile.source.is_dynamic();
286
287                            // Build the label text
288                            let label = if let Some(icon) = &profile.icon {
289                                format!("{} {}", icon, profile.name)
290                            } else {
291                                profile.name.clone()
292                            };
293
294                            // Add indicator for profiles with custom settings
295                            let has_custom = profile.command.is_some()
296                                || profile.working_directory.is_some()
297                                || profile.parent_id.is_some();
298                            let label = if has_custom {
299                                format!("{} ...", label)
300                            } else {
301                                label
302                            };
303
304                            ui.horizontal(|ui| {
305                                // Use selectable_label which has reliable click handling
306                                let response = ui.selectable_label(is_selected, &label);
307
308                                // Dynamic profile indicator
309                                if is_dynamic {
310                                    ui.label(
311                                        egui::RichText::new("[dynamic]")
312                                            .color(egui::Color32::from_rgb(100, 180, 255))
313                                            .small(),
314                                    );
315                                }
316
317                                // Single click selects
318                                if response.clicked() {
319                                    self.selected = Some(profile.id);
320                                }
321
322                                // Double click opens (using egui's built-in detection)
323                                if response.double_clicked() {
324                                    *action = ProfileDrawerAction::OpenProfile(profile.id);
325                                }
326
327                                // Show keyboard shortcut if defined
328                                if let Some(shortcut) = &profile.keyboard_shortcut {
329                                    ui.with_layout(
330                                        egui::Layout::right_to_left(egui::Align::Center),
331                                        |ui| {
332                                            ui.label(
333                                                egui::RichText::new(shortcut)
334                                                    .small()
335                                                    .color(egui::Color32::DARK_GRAY),
336                                            );
337                                        },
338                                    );
339                                }
340                            });
341
342                            // Show tags as small labels below the profile name
343                            if !profile.tags.is_empty() {
344                                ui.horizontal(|ui| {
345                                    ui.add_space(16.0); // Indent
346                                    for tag in &profile.tags {
347                                        ui.label(
348                                            egui::RichText::new(format!("#{}", tag))
349                                                .small()
350                                                .color(egui::Color32::from_rgb(100, 150, 200)),
351                                        );
352                                    }
353                                });
354                            }
355                        }
356                    });
357            }
358
359            // Action buttons (always visible at bottom)
360            ui.separator();
361            ui.horizontal(|ui| {
362                let open_enabled = self.selected.is_some();
363                if ui
364                    .add_enabled(open_enabled, egui::Button::new("Open"))
365                    .clicked()
366                    && let Some(id) = self.selected
367                {
368                    *action = ProfileDrawerAction::OpenProfile(id);
369                }
370            });
371        }
372    }
373}
374
375impl Default for ProfileDrawerUI {
376    fn default() -> Self {
377        Self::new()
378    }
379}