| 1 | //! Picker toolbar component with Accept/Cancel buttons. |
| 2 | //! |
| 3 | //! This toolbar replaces the normal toolbar when garfield runs in picker mode. |
| 4 | //! In save mode, also includes a filename textbox. |
| 5 | |
| 6 | use anyhow::Result; |
| 7 | use gartk_core::{Key, Point, Rect}; |
| 8 | use gartk_render::{Renderer, TextStyle}; |
| 9 | |
| 10 | /// Height of the picker toolbar (same as normal toolbar). |
| 11 | pub const PICKER_TOOLBAR_HEIGHT: u32 = 36; |
| 12 | |
| 13 | /// Button width. |
| 14 | const BUTTON_WIDTH: u32 = 100; |
| 15 | |
| 16 | /// Button height. |
| 17 | const BUTTON_HEIGHT: u32 = 28; |
| 18 | |
| 19 | /// Padding from edges. |
| 20 | const PADDING: i32 = 8; |
| 21 | |
| 22 | /// Gap between buttons. |
| 23 | const BUTTON_GAP: i32 = 12; |
| 24 | |
| 25 | /// Filename textbox minimum width. |
| 26 | const FILENAME_MIN_WIDTH: u32 = 200; |
| 27 | |
| 28 | /// Picker toolbar click result. |
| 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 30 | pub enum PickerToolbarClick { |
| 31 | /// Accept button clicked. |
| 32 | Accept, |
| 33 | /// Cancel button clicked. |
| 34 | Cancel, |
| 35 | /// Nothing clicked. |
| 36 | None, |
| 37 | } |
| 38 | |
| 39 | /// Picker toolbar with Accept and Cancel buttons. |
| 40 | pub struct PickerToolbar { |
| 41 | /// Toolbar bounds. |
| 42 | bounds: Rect, |
| 43 | /// Accept button label. |
| 44 | accept_label: String, |
| 45 | /// Cancel button label. |
| 46 | cancel_label: String, |
| 47 | /// Accept button bounds. |
| 48 | accept_bounds: Rect, |
| 49 | /// Cancel button bounds. |
| 50 | cancel_bounds: Rect, |
| 51 | /// Filter text bounds (for hover detection). |
| 52 | filter_bounds: Rect, |
| 53 | /// Hovered button (0 = accept, 1 = cancel, 2 = filename). |
| 54 | hovered: Option<usize>, |
| 55 | /// Whether filter text is hovered. |
| 56 | filter_hovered: bool, |
| 57 | /// Focused button for keyboard navigation (0 = accept, 1 = cancel, 2 = filename). |
| 58 | focused: usize, |
| 59 | /// Whether accept button is enabled (has valid selection). |
| 60 | accept_enabled: bool, |
| 61 | /// Filter description shown in toolbar (full text). |
| 62 | filter_description: Option<String>, |
| 63 | /// Whether this is save mode (shows filename textbox). |
| 64 | save_mode: bool, |
| 65 | /// Filename for save mode. |
| 66 | filename: String, |
| 67 | /// Filename textbox bounds. |
| 68 | filename_bounds: Rect, |
| 69 | /// Whether filename textbox is being edited. |
| 70 | filename_editing: bool, |
| 71 | /// Cursor position in filename (character index). |
| 72 | filename_cursor: usize, |
| 73 | /// Selection start in filename (if different from cursor, text is selected). |
| 74 | filename_selection_start: Option<usize>, |
| 75 | } |
| 76 | |
| 77 | impl PickerToolbar { |
| 78 | /// Create a new picker toolbar. |
| 79 | pub fn new(bounds: Rect, accept_label: String) -> Self { |
| 80 | let mut toolbar = Self { |
| 81 | bounds, |
| 82 | accept_label, |
| 83 | cancel_label: "Cancel".to_string(), |
| 84 | accept_bounds: Rect::default(), |
| 85 | cancel_bounds: Rect::default(), |
| 86 | filter_bounds: Rect::default(), |
| 87 | hovered: None, |
| 88 | filter_hovered: false, |
| 89 | focused: 0, |
| 90 | accept_enabled: false, |
| 91 | filter_description: None, |
| 92 | save_mode: false, |
| 93 | filename: String::new(), |
| 94 | filename_bounds: Rect::default(), |
| 95 | filename_editing: false, |
| 96 | filename_cursor: 0, |
| 97 | filename_selection_start: None, |
| 98 | }; |
| 99 | toolbar.layout(); |
| 100 | toolbar |
| 101 | } |
| 102 | |
| 103 | /// Create a new picker toolbar for save mode with suggested filename. |
| 104 | pub fn new_save_mode(bounds: Rect, accept_label: String, suggested_filename: String) -> Self { |
| 105 | let cursor_pos = suggested_filename.len(); |
| 106 | let mut toolbar = Self { |
| 107 | bounds, |
| 108 | accept_label, |
| 109 | cancel_label: "Cancel".to_string(), |
| 110 | accept_bounds: Rect::default(), |
| 111 | cancel_bounds: Rect::default(), |
| 112 | filter_bounds: Rect::default(), |
| 113 | hovered: None, |
| 114 | filter_hovered: false, |
| 115 | focused: 2, // Start focused on filename |
| 116 | accept_enabled: true, // Enable by default in save mode |
| 117 | filter_description: None, |
| 118 | save_mode: true, |
| 119 | filename: suggested_filename, |
| 120 | filename_bounds: Rect::default(), |
| 121 | filename_editing: true, // Start editing |
| 122 | filename_cursor: cursor_pos, |
| 123 | filename_selection_start: Some(0), // Select all |
| 124 | }; |
| 125 | toolbar.layout(); |
| 126 | toolbar |
| 127 | } |
| 128 | |
| 129 | /// Set bounds. |
| 130 | pub fn set_bounds(&mut self, bounds: Rect) { |
| 131 | self.bounds = bounds; |
| 132 | self.layout(); |
| 133 | } |
| 134 | |
| 135 | /// Layout buttons and filter area. |
| 136 | fn layout(&mut self) { |
| 137 | // Cancel button (rightmost) |
| 138 | let cancel_x = self.bounds.x + self.bounds.width as i32 - BUTTON_WIDTH as i32 - PADDING; |
| 139 | let button_y = self.bounds.y + (self.bounds.height as i32 - BUTTON_HEIGHT as i32) / 2; |
| 140 | self.cancel_bounds = Rect::new(cancel_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT); |
| 141 | |
| 142 | // Accept button (to the left of cancel) |
| 143 | let accept_x = cancel_x - BUTTON_WIDTH as i32 - BUTTON_GAP; |
| 144 | self.accept_bounds = Rect::new(accept_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT); |
| 145 | |
| 146 | if self.save_mode { |
| 147 | // Filename textbox (left side, takes available space) |
| 148 | let filename_x = self.bounds.x + PADDING; |
| 149 | let available_width = (accept_x - BUTTON_GAP - filename_x).max(FILENAME_MIN_WIDTH as i32) as u32; |
| 150 | self.filename_bounds = Rect::new(filename_x, button_y, available_width, BUTTON_HEIGHT); |
| 151 | // No filter area in save mode |
| 152 | self.filter_bounds = Rect::default(); |
| 153 | } else { |
| 154 | // Filter text area (left side, up to accept button) |
| 155 | let filter_x = self.bounds.x + PADDING; |
| 156 | let filter_width = (accept_x - BUTTON_GAP - filter_x).max(0) as u32; |
| 157 | self.filter_bounds = Rect::new(filter_x, self.bounds.y, filter_width, self.bounds.height); |
| 158 | self.filename_bounds = Rect::default(); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | /// Get max width available for filter text. |
| 163 | fn max_filter_width(&self) -> u32 { |
| 164 | self.filter_bounds.width.saturating_sub(8) // Small padding |
| 165 | } |
| 166 | |
| 167 | /// Set whether accept button is enabled. |
| 168 | pub fn set_accept_enabled(&mut self, enabled: bool) { |
| 169 | self.accept_enabled = enabled; |
| 170 | } |
| 171 | |
| 172 | /// Set filter description shown in toolbar. |
| 173 | pub fn set_filter_description(&mut self, desc: Option<String>) { |
| 174 | self.filter_description = desc; |
| 175 | } |
| 176 | |
| 177 | /// Handle mouse move. Returns true if hovered state changed. |
| 178 | pub fn on_mouse_move(&mut self, pos: Point) -> bool { |
| 179 | let mut changed = false; |
| 180 | |
| 181 | if !self.bounds.contains_point(pos) { |
| 182 | if self.hovered.is_some() { |
| 183 | self.hovered = None; |
| 184 | changed = true; |
| 185 | } |
| 186 | if self.filter_hovered { |
| 187 | self.filter_hovered = false; |
| 188 | changed = true; |
| 189 | } |
| 190 | return changed; |
| 191 | } |
| 192 | |
| 193 | let new_hovered = if self.accept_bounds.contains_point(pos) { |
| 194 | Some(0) |
| 195 | } else if self.cancel_bounds.contains_point(pos) { |
| 196 | Some(1) |
| 197 | } else if self.save_mode && self.filename_bounds.contains_point(pos) { |
| 198 | Some(2) |
| 199 | } else { |
| 200 | None |
| 201 | }; |
| 202 | |
| 203 | if new_hovered != self.hovered { |
| 204 | self.hovered = new_hovered; |
| 205 | changed = true; |
| 206 | } |
| 207 | |
| 208 | // Check if hovering over filter text area |
| 209 | let new_filter_hovered = self.filter_bounds.contains_point(pos) && self.filter_description.is_some(); |
| 210 | if new_filter_hovered != self.filter_hovered { |
| 211 | self.filter_hovered = new_filter_hovered; |
| 212 | changed = true; |
| 213 | } |
| 214 | |
| 215 | changed |
| 216 | } |
| 217 | |
| 218 | /// Get tooltip text if hovering over truncated filter. |
| 219 | pub fn get_tooltip(&self) -> Option<&str> { |
| 220 | if self.filter_hovered { |
| 221 | self.filter_description.as_deref() |
| 222 | } else { |
| 223 | None |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | /// Check if filter is currently hovered. |
| 228 | pub fn is_filter_hovered(&self) -> bool { |
| 229 | self.filter_hovered |
| 230 | } |
| 231 | |
| 232 | /// Handle click. Returns the action if a button was clicked. |
| 233 | pub fn on_click(&mut self, pos: Point) -> PickerToolbarClick { |
| 234 | if self.accept_bounds.contains_point(pos) && self.accept_enabled { |
| 235 | self.filename_editing = false; |
| 236 | PickerToolbarClick::Accept |
| 237 | } else if self.cancel_bounds.contains_point(pos) { |
| 238 | self.filename_editing = false; |
| 239 | PickerToolbarClick::Cancel |
| 240 | } else if self.save_mode && self.filename_bounds.contains_point(pos) { |
| 241 | // Click on filename textbox - start editing |
| 242 | self.filename_editing = true; |
| 243 | self.focused = 2; |
| 244 | // Position cursor at click point (simplified: just move to end) |
| 245 | self.filename_cursor = self.filename.len(); |
| 246 | self.filename_selection_start = None; |
| 247 | PickerToolbarClick::None |
| 248 | } else { |
| 249 | // Click elsewhere stops editing |
| 250 | self.filename_editing = false; |
| 251 | PickerToolbarClick::None |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /// Cycle focus between elements. |
| 256 | pub fn cycle_focus(&mut self) { |
| 257 | if self.save_mode { |
| 258 | // Cycle: filename (2) -> accept (0) -> cancel (1) -> filename |
| 259 | self.focused = match self.focused { |
| 260 | 2 => 0, |
| 261 | 0 => 1, |
| 262 | _ => 2, |
| 263 | }; |
| 264 | self.filename_editing = self.focused == 2; |
| 265 | } else { |
| 266 | self.focused = 1 - self.focused; |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | /// Activate focused element. |
| 271 | pub fn activate_focused(&self) -> PickerToolbarClick { |
| 272 | if self.focused == 0 && self.accept_enabled { |
| 273 | PickerToolbarClick::Accept |
| 274 | } else if self.focused == 1 { |
| 275 | PickerToolbarClick::Cancel |
| 276 | } else { |
| 277 | // Focused on filename - Enter should accept if valid |
| 278 | if self.save_mode && !self.filename.is_empty() { |
| 279 | PickerToolbarClick::Accept |
| 280 | } else { |
| 281 | PickerToolbarClick::None |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | /// Whether filename textbox is being edited. |
| 287 | pub fn is_editing_filename(&self) -> bool { |
| 288 | self.save_mode && self.filename_editing |
| 289 | } |
| 290 | |
| 291 | /// Get the current filename. |
| 292 | pub fn filename(&self) -> &str { |
| 293 | &self.filename |
| 294 | } |
| 295 | |
| 296 | /// Set the filename (e.g., when clicking a file in save mode). |
| 297 | pub fn set_filename(&mut self, filename: &str) { |
| 298 | self.filename = filename.to_string(); |
| 299 | self.filename_cursor = self.filename.len(); |
| 300 | self.filename_selection_start = None; |
| 301 | } |
| 302 | |
| 303 | /// Handle keyboard input for filename editing. Returns true if handled. |
| 304 | pub fn handle_key(&mut self, key: &Key) -> bool { |
| 305 | if !self.filename_editing { |
| 306 | return false; |
| 307 | } |
| 308 | |
| 309 | match key { |
| 310 | Key::Char(c) => { |
| 311 | // Don't allow path separators in filename |
| 312 | if *c != '/' && *c != '\\' && *c != '\0' { |
| 313 | // Delete selection first if any |
| 314 | self.delete_selection(); |
| 315 | self.filename.insert(self.filename_cursor, *c); |
| 316 | self.filename_cursor += 1; |
| 317 | } |
| 318 | true |
| 319 | } |
| 320 | Key::Backspace => { |
| 321 | if self.filename_selection_start.is_some() { |
| 322 | self.delete_selection(); |
| 323 | } else if self.filename_cursor > 0 { |
| 324 | self.filename_cursor -= 1; |
| 325 | self.filename.remove(self.filename_cursor); |
| 326 | } |
| 327 | true |
| 328 | } |
| 329 | Key::Delete => { |
| 330 | if self.filename_selection_start.is_some() { |
| 331 | self.delete_selection(); |
| 332 | } else if self.filename_cursor < self.filename.len() { |
| 333 | self.filename.remove(self.filename_cursor); |
| 334 | } |
| 335 | true |
| 336 | } |
| 337 | Key::Left => { |
| 338 | if self.filename_cursor > 0 { |
| 339 | self.filename_cursor -= 1; |
| 340 | } |
| 341 | self.filename_selection_start = None; |
| 342 | true |
| 343 | } |
| 344 | Key::Right => { |
| 345 | if self.filename_cursor < self.filename.len() { |
| 346 | self.filename_cursor += 1; |
| 347 | } |
| 348 | self.filename_selection_start = None; |
| 349 | true |
| 350 | } |
| 351 | Key::Home => { |
| 352 | self.filename_cursor = 0; |
| 353 | self.filename_selection_start = None; |
| 354 | true |
| 355 | } |
| 356 | Key::End => { |
| 357 | self.filename_cursor = self.filename.len(); |
| 358 | self.filename_selection_start = None; |
| 359 | true |
| 360 | } |
| 361 | _ => false, |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | /// Delete selected text. |
| 366 | fn delete_selection(&mut self) { |
| 367 | if let Some(start) = self.filename_selection_start.take() { |
| 368 | let (from, to) = if start < self.filename_cursor { |
| 369 | (start, self.filename_cursor) |
| 370 | } else { |
| 371 | (self.filename_cursor, start) |
| 372 | }; |
| 373 | self.filename.drain(from..to); |
| 374 | self.filename_cursor = from; |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | /// Select all text in filename. |
| 379 | pub fn select_all(&mut self) { |
| 380 | if self.save_mode { |
| 381 | self.filename_selection_start = Some(0); |
| 382 | self.filename_cursor = self.filename.len(); |
| 383 | self.filename_editing = true; |
| 384 | self.focused = 2; |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | /// Check if point is within toolbar bounds. |
| 389 | pub fn contains_point(&self, pos: Point) -> bool { |
| 390 | self.bounds.contains_point(pos) |
| 391 | } |
| 392 | |
| 393 | /// Render the toolbar. |
| 394 | pub fn render(&self, renderer: &Renderer) -> Result<()> { |
| 395 | let theme = renderer.theme(); |
| 396 | |
| 397 | // Toolbar background |
| 398 | renderer.fill_rect(self.bounds, theme.item_background)?; |
| 399 | |
| 400 | // Save mode: filename textbox |
| 401 | if self.save_mode { |
| 402 | self.render_filename_textbox(renderer)?; |
| 403 | } else if let Some(desc) = &self.filter_description { |
| 404 | // Filter description (left side, truncated with ellipsis) |
| 405 | let text_style = TextStyle::new() |
| 406 | .font_family(&theme.font_family) |
| 407 | .font_size(theme.font_size - 1.0) |
| 408 | .color(if self.filter_hovered { theme.foreground } else { theme.item_foreground }); |
| 409 | |
| 410 | let max_width = self.max_filter_width() as f64; |
| 411 | let display_text = self.truncate_with_ellipsis(desc, max_width, renderer, &text_style)?; |
| 412 | |
| 413 | let text_x = (self.bounds.x + PADDING + 4) as f64; |
| 414 | let text_y = (self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2) as f64; |
| 415 | renderer.text(&display_text, text_x, text_y, &text_style)?; |
| 416 | |
| 417 | // Show tooltip if hovered |
| 418 | if self.filter_hovered { |
| 419 | self.render_tooltip(renderer, desc)?; |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | // Accept button |
| 424 | let accept_hovered = self.hovered == Some(0); |
| 425 | let accept_focused = self.focused == 0; |
| 426 | let (accept_bg, accept_fg) = if !self.accept_enabled { |
| 427 | // Disabled state - use input_background for visibility |
| 428 | (theme.input_background.with_alpha(0.5), theme.item_foreground.with_alpha(0.5)) |
| 429 | } else if accept_hovered || accept_focused { |
| 430 | // Active state - use accent color |
| 431 | (theme.selection_background, theme.foreground) |
| 432 | } else { |
| 433 | // Normal state - accent color slightly dimmed |
| 434 | (theme.selection_background.with_alpha(0.8), theme.foreground) |
| 435 | }; |
| 436 | |
| 437 | renderer.fill_rounded_rect(self.accept_bounds, 4.0, accept_bg)?; |
| 438 | if accept_focused && self.accept_enabled { |
| 439 | renderer.stroke_rounded_rect(self.accept_bounds, 4.0, theme.foreground, 2.0)?; |
| 440 | } |
| 441 | |
| 442 | let button_style = TextStyle::new() |
| 443 | .font_family(&theme.font_family) |
| 444 | .font_size(theme.font_size) |
| 445 | .color(accept_fg); |
| 446 | |
| 447 | let accept_metrics = renderer.measure_text(&self.accept_label, &button_style)?; |
| 448 | let accept_text_x = self.accept_bounds.x + (self.accept_bounds.width as i32 - accept_metrics.width as i32) / 2; |
| 449 | let accept_text_y = self.accept_bounds.y + (self.accept_bounds.height as i32 - accept_metrics.height as i32) / 2; |
| 450 | renderer.text(&self.accept_label, accept_text_x as f64, accept_text_y as f64, &button_style)?; |
| 451 | |
| 452 | // Cancel button - use input_background for visibility |
| 453 | let cancel_hovered = self.hovered == Some(1); |
| 454 | let cancel_focused = self.focused == 1; |
| 455 | let cancel_bg = if cancel_hovered || cancel_focused { |
| 456 | theme.item_hover_background |
| 457 | } else { |
| 458 | theme.input_background |
| 459 | }; |
| 460 | |
| 461 | renderer.fill_rounded_rect(self.cancel_bounds, 4.0, cancel_bg)?; |
| 462 | if cancel_focused { |
| 463 | renderer.stroke_rounded_rect(self.cancel_bounds, 4.0, theme.foreground, 2.0)?; |
| 464 | } |
| 465 | renderer.stroke_rounded_rect(self.cancel_bounds, 4.0, theme.border, 1.0)?; |
| 466 | |
| 467 | let cancel_style = TextStyle::new() |
| 468 | .font_family(&theme.font_family) |
| 469 | .font_size(theme.font_size) |
| 470 | .color(theme.foreground); |
| 471 | |
| 472 | let cancel_metrics = renderer.measure_text(&self.cancel_label, &cancel_style)?; |
| 473 | let cancel_text_x = self.cancel_bounds.x + (self.cancel_bounds.width as i32 - cancel_metrics.width as i32) / 2; |
| 474 | let cancel_text_y = self.cancel_bounds.y + (self.cancel_bounds.height as i32 - cancel_metrics.height as i32) / 2; |
| 475 | renderer.text(&self.cancel_label, cancel_text_x as f64, cancel_text_y as f64, &cancel_style)?; |
| 476 | |
| 477 | // Bottom border |
| 478 | let border_y = self.bounds.y + self.bounds.height as i32 - 1; |
| 479 | renderer.fill_rect( |
| 480 | Rect::new(self.bounds.x, border_y, self.bounds.width, 1), |
| 481 | theme.border, |
| 482 | )?; |
| 483 | |
| 484 | Ok(()) |
| 485 | } |
| 486 | |
| 487 | /// Render the filename textbox for save mode. |
| 488 | fn render_filename_textbox(&self, renderer: &Renderer) -> Result<()> { |
| 489 | let theme = renderer.theme(); |
| 490 | let filename_focused = self.focused == 2; |
| 491 | let filename_hovered = self.hovered == Some(2); |
| 492 | |
| 493 | // Textbox background - brighter when editing/focused |
| 494 | let bg_color = if self.filename_editing { |
| 495 | theme.background |
| 496 | } else if filename_focused || filename_hovered { |
| 497 | theme.item_hover_background |
| 498 | } else { |
| 499 | theme.input_background |
| 500 | }; |
| 501 | |
| 502 | renderer.fill_rounded_rect(self.filename_bounds, 4.0, bg_color)?; |
| 503 | |
| 504 | // Border - thick accent color when editing, thinner when just focused |
| 505 | if self.filename_editing { |
| 506 | // Editing: prominent accent border |
| 507 | renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.selection_background, 2.0)?; |
| 508 | // Inner glow effect |
| 509 | let inner = Rect::new( |
| 510 | self.filename_bounds.x + 1, |
| 511 | self.filename_bounds.y + 1, |
| 512 | self.filename_bounds.width.saturating_sub(2), |
| 513 | self.filename_bounds.height.saturating_sub(2), |
| 514 | ); |
| 515 | renderer.stroke_rounded_rect(inner, 3.0, theme.selection_background.with_alpha(0.3), 1.0)?; |
| 516 | } else if filename_focused { |
| 517 | // Focused but not editing: white/foreground border |
| 518 | renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.foreground, 2.0)?; |
| 519 | } else { |
| 520 | // Normal: subtle border |
| 521 | renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.border, 1.0)?; |
| 522 | } |
| 523 | |
| 524 | // "Filename:" label |
| 525 | let label = "Filename:"; |
| 526 | let label_style = TextStyle::new() |
| 527 | .font_family(&theme.font_family) |
| 528 | .font_size(theme.font_size - 1.0) |
| 529 | .color(theme.item_foreground); |
| 530 | |
| 531 | let label_metrics = renderer.measure_text(label, &label_style)?; |
| 532 | let label_x = self.filename_bounds.x + 8; |
| 533 | let label_y = self.filename_bounds.y + (self.filename_bounds.height as i32 - label_metrics.height as i32) / 2; |
| 534 | renderer.text(label, label_x as f64, label_y as f64, &label_style)?; |
| 535 | |
| 536 | // Text content area (after label) |
| 537 | let text_padding = 8; |
| 538 | let text_x_start = label_x + label_metrics.width as i32 + text_padding; |
| 539 | let text_max_width = (self.filename_bounds.x + self.filename_bounds.width as i32 - text_x_start - text_padding) as u32; |
| 540 | |
| 541 | let text_style = TextStyle::new() |
| 542 | .font_family(&theme.font_family) |
| 543 | .font_size(theme.font_size) |
| 544 | .color(theme.foreground); |
| 545 | |
| 546 | // Selection highlight |
| 547 | if let Some(sel_start) = self.filename_selection_start { |
| 548 | if sel_start != self.filename_cursor { |
| 549 | let (from, to) = if sel_start < self.filename_cursor { |
| 550 | (sel_start, self.filename_cursor) |
| 551 | } else { |
| 552 | (self.filename_cursor, sel_start) |
| 553 | }; |
| 554 | |
| 555 | // Measure text up to selection start and end |
| 556 | let before_sel = &self.filename[..from]; |
| 557 | let selection = &self.filename[from..to]; |
| 558 | |
| 559 | let before_width = if before_sel.is_empty() { |
| 560 | 0 |
| 561 | } else { |
| 562 | renderer.measure_text(before_sel, &text_style)?.width |
| 563 | }; |
| 564 | let sel_width = renderer.measure_text(selection, &text_style)?.width; |
| 565 | |
| 566 | let sel_x = text_x_start + before_width as i32; |
| 567 | let sel_rect = Rect::new( |
| 568 | sel_x, |
| 569 | self.filename_bounds.y + 4, |
| 570 | sel_width.min(text_max_width), |
| 571 | self.filename_bounds.height - 8, |
| 572 | ); |
| 573 | renderer.fill_rect(sel_rect, theme.selection_background.with_alpha(0.4))?; |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | // Filename text |
| 578 | let text_y = self.filename_bounds.y + (self.filename_bounds.height as i32 - theme.font_size as i32) / 2; |
| 579 | renderer.text(&self.filename, text_x_start as f64, text_y as f64, &text_style)?; |
| 580 | |
| 581 | // Cursor when editing |
| 582 | if self.filename_editing { |
| 583 | let cursor_text = &self.filename[..self.filename_cursor]; |
| 584 | let cursor_offset = if cursor_text.is_empty() { |
| 585 | 0 |
| 586 | } else { |
| 587 | renderer.measure_text(cursor_text, &text_style)?.width |
| 588 | }; |
| 589 | |
| 590 | let cursor_x = text_x_start + cursor_offset as i32; |
| 591 | let cursor_rect = Rect::new( |
| 592 | cursor_x, |
| 593 | self.filename_bounds.y + 6, |
| 594 | 2, |
| 595 | self.filename_bounds.height - 12, |
| 596 | ); |
| 597 | renderer.fill_rect(cursor_rect, theme.foreground)?; |
| 598 | } |
| 599 | |
| 600 | Ok(()) |
| 601 | } |
| 602 | |
| 603 | /// Truncate text with ellipsis if it exceeds max width. |
| 604 | fn truncate_with_ellipsis(&self, text: &str, max_width: f64, renderer: &Renderer, style: &TextStyle) -> Result<String> { |
| 605 | let metrics = renderer.measure_text(text, style)?; |
| 606 | let max_width = max_width as u32; |
| 607 | if metrics.width <= max_width { |
| 608 | return Ok(text.to_string()); |
| 609 | } |
| 610 | |
| 611 | // Need to truncate - binary search for the right length |
| 612 | let ellipsis = "..."; |
| 613 | let ellipsis_width = renderer.measure_text(ellipsis, style)?.width; |
| 614 | let target_width = max_width.saturating_sub(ellipsis_width); |
| 615 | |
| 616 | if target_width == 0 { |
| 617 | return Ok(ellipsis.to_string()); |
| 618 | } |
| 619 | |
| 620 | // Find the longest prefix that fits |
| 621 | let mut end = text.len(); |
| 622 | for (i, _) in text.char_indices().rev() { |
| 623 | let prefix = &text[..i]; |
| 624 | let prefix_width = renderer.measure_text(prefix, style)?.width; |
| 625 | if prefix_width <= target_width { |
| 626 | end = i; |
| 627 | break; |
| 628 | } |
| 629 | } |
| 630 | |
| 631 | if end == 0 { |
| 632 | Ok(ellipsis.to_string()) |
| 633 | } else { |
| 634 | Ok(format!("{}{}", &text[..end], ellipsis)) |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | /// Render tooltip showing full filter text as multiline. |
| 639 | fn render_tooltip(&self, renderer: &Renderer, text: &str) -> Result<()> { |
| 640 | let theme = renderer.theme(); |
| 641 | |
| 642 | let tooltip_style = TextStyle::new() |
| 643 | .font_family(&theme.font_family) |
| 644 | .font_size(theme.font_size - 2.0) |
| 645 | .color(theme.foreground); |
| 646 | |
| 647 | // Parse filter patterns and format as multiline |
| 648 | // Remove "Filter: " prefix if present |
| 649 | let filter_text = text.strip_prefix("Filter: ").unwrap_or(text); |
| 650 | |
| 651 | // Split by semicolon or comma and clean up patterns |
| 652 | let patterns: Vec<&str> = filter_text |
| 653 | .split(|c| c == ';' || c == ',') |
| 654 | .map(|s| s.trim()) |
| 655 | .filter(|s| !s.is_empty()) |
| 656 | .collect(); |
| 657 | |
| 658 | // Format into columns (4 patterns per row max) |
| 659 | let cols = 4; |
| 660 | let mut lines: Vec<String> = Vec::new(); |
| 661 | lines.push("Accepted file types:".to_string()); |
| 662 | |
| 663 | for chunk in patterns.chunks(cols) { |
| 664 | let line = chunk.join(" "); |
| 665 | lines.push(line); |
| 666 | } |
| 667 | |
| 668 | // Measure dimensions |
| 669 | let padding = 10u32; |
| 670 | let line_height = (theme.font_size - 2.0) as u32 + 4; |
| 671 | let mut max_width = 0u32; |
| 672 | |
| 673 | for line in &lines { |
| 674 | let metrics = renderer.measure_text(line, &tooltip_style)?; |
| 675 | max_width = max_width.max(metrics.width); |
| 676 | } |
| 677 | |
| 678 | let tooltip_width = max_width + padding * 2; |
| 679 | let tooltip_height = (lines.len() as u32 * line_height) + padding * 2; |
| 680 | |
| 681 | // Position tooltip above the filter area, but keep on screen |
| 682 | let tooltip_x = self.filter_bounds.x.max(4); |
| 683 | let tooltip_y = self.bounds.y - tooltip_height as i32 - 4; |
| 684 | |
| 685 | let tooltip_bounds = Rect::new(tooltip_x, tooltip_y, tooltip_width, tooltip_height); |
| 686 | |
| 687 | // Background with border and shadow effect |
| 688 | let shadow_bounds = Rect::new(tooltip_x + 2, tooltip_y + 2, tooltip_width, tooltip_height); |
| 689 | renderer.fill_rounded_rect(shadow_bounds, 6.0, theme.background.with_alpha(0.3))?; |
| 690 | renderer.fill_rounded_rect(tooltip_bounds, 6.0, theme.background)?; |
| 691 | renderer.stroke_rounded_rect(tooltip_bounds, 6.0, theme.border, 1.0)?; |
| 692 | |
| 693 | // Render each line |
| 694 | let mut y = tooltip_y + padding as i32; |
| 695 | for (i, line) in lines.iter().enumerate() { |
| 696 | let style = if i == 0 { |
| 697 | // Header line slightly brighter |
| 698 | TextStyle::new() |
| 699 | .font_family(&theme.font_family) |
| 700 | .font_size(theme.font_size - 2.0) |
| 701 | .color(theme.foreground) |
| 702 | } else { |
| 703 | tooltip_style.clone() |
| 704 | }; |
| 705 | |
| 706 | renderer.text( |
| 707 | line, |
| 708 | (tooltip_x + padding as i32) as f64, |
| 709 | y as f64, |
| 710 | &style, |
| 711 | )?; |
| 712 | y += line_height as i32; |
| 713 | } |
| 714 | |
| 715 | Ok(()) |
| 716 | } |
| 717 | } |
| 718 |