@@ -15,6 +15,12 @@ const BUTTON_WIDTH: u32 = 100; |
| 15 | 15 | /// Button height. |
| 16 | 16 | const BUTTON_HEIGHT: u32 = 28; |
| 17 | 17 | |
| 18 | +/// Padding from edges. |
| 19 | +const PADDING: i32 = 8; |
| 20 | + |
| 21 | +/// Gap between buttons. |
| 22 | +const BUTTON_GAP: i32 = 12; |
| 23 | + |
| 18 | 24 | /// Picker toolbar click result. |
| 19 | 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 20 | 26 | pub enum PickerToolbarClick { |
@@ -38,13 +44,17 @@ pub struct PickerToolbar { |
| 38 | 44 | accept_bounds: Rect, |
| 39 | 45 | /// Cancel button bounds. |
| 40 | 46 | cancel_bounds: Rect, |
| 47 | + /// Filter text bounds (for hover detection). |
| 48 | + filter_bounds: Rect, |
| 41 | 49 | /// Hovered button (0 = accept, 1 = cancel). |
| 42 | 50 | hovered: Option<usize>, |
| 51 | + /// Whether filter text is hovered. |
| 52 | + filter_hovered: bool, |
| 43 | 53 | /// Focused button for keyboard navigation (0 = accept, 1 = cancel). |
| 44 | 54 | focused: usize, |
| 45 | 55 | /// Whether accept button is enabled (has valid selection). |
| 46 | 56 | accept_enabled: bool, |
| 47 | | - /// Filter description shown in toolbar. |
| 57 | + /// Filter description shown in toolbar (full text). |
| 48 | 58 | filter_description: Option<String>, |
| 49 | 59 | } |
| 50 | 60 | |
@@ -57,35 +67,43 @@ impl PickerToolbar { |
| 57 | 67 | cancel_label: "Cancel".to_string(), |
| 58 | 68 | accept_bounds: Rect::default(), |
| 59 | 69 | cancel_bounds: Rect::default(), |
| 70 | + filter_bounds: Rect::default(), |
| 60 | 71 | hovered: None, |
| 72 | + filter_hovered: false, |
| 61 | 73 | focused: 0, |
| 62 | 74 | accept_enabled: false, |
| 63 | 75 | filter_description: None, |
| 64 | 76 | }; |
| 65 | | - toolbar.layout_buttons(); |
| 77 | + toolbar.layout(); |
| 66 | 78 | toolbar |
| 67 | 79 | } |
| 68 | 80 | |
| 69 | 81 | /// Set bounds. |
| 70 | 82 | pub fn set_bounds(&mut self, bounds: Rect) { |
| 71 | 83 | self.bounds = bounds; |
| 72 | | - self.layout_buttons(); |
| 84 | + self.layout(); |
| 73 | 85 | } |
| 74 | 86 | |
| 75 | | - /// Layout buttons. |
| 76 | | - fn layout_buttons(&mut self) { |
| 77 | | - // Buttons are right-aligned |
| 78 | | - let padding = 8; |
| 79 | | - let button_gap = 12; |
| 80 | | - |
| 87 | + /// Layout buttons and filter area. |
| 88 | + fn layout(&mut self) { |
| 81 | 89 | // Cancel button (rightmost) |
| 82 | | - let cancel_x = self.bounds.x + self.bounds.width as i32 - BUTTON_WIDTH as i32 - padding; |
| 90 | + let cancel_x = self.bounds.x + self.bounds.width as i32 - BUTTON_WIDTH as i32 - PADDING; |
| 83 | 91 | let button_y = self.bounds.y + (self.bounds.height as i32 - BUTTON_HEIGHT as i32) / 2; |
| 84 | 92 | self.cancel_bounds = Rect::new(cancel_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT); |
| 85 | 93 | |
| 86 | 94 | // Accept button (to the left of cancel) |
| 87 | | - let accept_x = cancel_x - BUTTON_WIDTH as i32 - button_gap; |
| 95 | + let accept_x = cancel_x - BUTTON_WIDTH as i32 - BUTTON_GAP; |
| 88 | 96 | self.accept_bounds = Rect::new(accept_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT); |
| 97 | + |
| 98 | + // Filter text area (left side, up to accept button) |
| 99 | + let filter_x = self.bounds.x + PADDING; |
| 100 | + let filter_width = (accept_x - BUTTON_GAP - filter_x).max(0) as u32; |
| 101 | + self.filter_bounds = Rect::new(filter_x, self.bounds.y, filter_width, self.bounds.height); |
| 102 | + } |
| 103 | + |
| 104 | + /// Get max width available for filter text. |
| 105 | + fn max_filter_width(&self) -> u32 { |
| 106 | + self.filter_bounds.width.saturating_sub(8) // Small padding |
| 89 | 107 | } |
| 90 | 108 | |
| 91 | 109 | /// Set whether accept button is enabled. |
@@ -100,12 +118,18 @@ impl PickerToolbar { |
| 100 | 118 | |
| 101 | 119 | /// Handle mouse move. Returns true if hovered state changed. |
| 102 | 120 | pub fn on_mouse_move(&mut self, pos: Point) -> bool { |
| 121 | + let mut changed = false; |
| 122 | + |
| 103 | 123 | if !self.bounds.contains_point(pos) { |
| 104 | 124 | if self.hovered.is_some() { |
| 105 | 125 | self.hovered = None; |
| 106 | | - return true; |
| 126 | + changed = true; |
| 107 | 127 | } |
| 108 | | - return false; |
| 128 | + if self.filter_hovered { |
| 129 | + self.filter_hovered = false; |
| 130 | + changed = true; |
| 131 | + } |
| 132 | + return changed; |
| 109 | 133 | } |
| 110 | 134 | |
| 111 | 135 | let new_hovered = if self.accept_bounds.contains_point(pos) { |
@@ -116,11 +140,35 @@ impl PickerToolbar { |
| 116 | 140 | None |
| 117 | 141 | }; |
| 118 | 142 | |
| 119 | | - let changed = new_hovered != self.hovered; |
| 120 | | - self.hovered = new_hovered; |
| 143 | + if new_hovered != self.hovered { |
| 144 | + self.hovered = new_hovered; |
| 145 | + changed = true; |
| 146 | + } |
| 147 | + |
| 148 | + // Check if hovering over filter text area |
| 149 | + let new_filter_hovered = self.filter_bounds.contains_point(pos) && self.filter_description.is_some(); |
| 150 | + if new_filter_hovered != self.filter_hovered { |
| 151 | + self.filter_hovered = new_filter_hovered; |
| 152 | + changed = true; |
| 153 | + } |
| 154 | + |
| 121 | 155 | changed |
| 122 | 156 | } |
| 123 | 157 | |
| 158 | + /// Get tooltip text if hovering over truncated filter. |
| 159 | + pub fn get_tooltip(&self) -> Option<&str> { |
| 160 | + if self.filter_hovered { |
| 161 | + self.filter_description.as_deref() |
| 162 | + } else { |
| 163 | + None |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + /// Check if filter is currently hovered. |
| 168 | + pub fn is_filter_hovered(&self) -> bool { |
| 169 | + self.filter_hovered |
| 170 | + } |
| 171 | + |
| 124 | 172 | /// Handle click. Returns the action if a button was clicked. |
| 125 | 173 | pub fn on_click(&self, pos: Point) -> PickerToolbarClick { |
| 126 | 174 | if self.accept_bounds.contains_point(pos) && self.accept_enabled { |
@@ -160,19 +208,24 @@ impl PickerToolbar { |
| 160 | 208 | // Toolbar background |
| 161 | 209 | renderer.fill_rect(self.bounds, theme.item_background)?; |
| 162 | 210 | |
| 163 | | - // Filter description (left side) |
| 211 | + // Filter description (left side, truncated with ellipsis) |
| 164 | 212 | if let Some(desc) = &self.filter_description { |
| 165 | 213 | let text_style = TextStyle::new() |
| 166 | 214 | .font_family(&theme.font_family) |
| 167 | 215 | .font_size(theme.font_size - 1.0) |
| 168 | | - .color(theme.item_foreground); |
| 216 | + .color(if self.filter_hovered { theme.foreground } else { theme.item_foreground }); |
| 169 | 217 | |
| 170 | | - renderer.text( |
| 171 | | - desc, |
| 172 | | - (self.bounds.x + 12) as f64, |
| 173 | | - (self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2) as f64, |
| 174 | | - &text_style, |
| 175 | | - )?; |
| 218 | + let max_width = self.max_filter_width() as f64; |
| 219 | + let display_text = self.truncate_with_ellipsis(desc, max_width, renderer, &text_style)?; |
| 220 | + |
| 221 | + let text_x = (self.bounds.x + PADDING + 4) as f64; |
| 222 | + let text_y = (self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2) as f64; |
| 223 | + renderer.text(&display_text, text_x, text_y, &text_style)?; |
| 224 | + |
| 225 | + // Show tooltip if hovered |
| 226 | + if self.filter_hovered { |
| 227 | + self.render_tooltip(renderer, desc)?; |
| 228 | + } |
| 176 | 229 | } |
| 177 | 230 | |
| 178 | 231 | // Accept button |
@@ -238,4 +291,119 @@ impl PickerToolbar { |
| 238 | 291 | |
| 239 | 292 | Ok(()) |
| 240 | 293 | } |
| 294 | + |
| 295 | + /// Truncate text with ellipsis if it exceeds max width. |
| 296 | + fn truncate_with_ellipsis(&self, text: &str, max_width: f64, renderer: &Renderer, style: &TextStyle) -> Result<String> { |
| 297 | + let metrics = renderer.measure_text(text, style)?; |
| 298 | + let max_width = max_width as u32; |
| 299 | + if metrics.width <= max_width { |
| 300 | + return Ok(text.to_string()); |
| 301 | + } |
| 302 | + |
| 303 | + // Need to truncate - binary search for the right length |
| 304 | + let ellipsis = "..."; |
| 305 | + let ellipsis_width = renderer.measure_text(ellipsis, style)?.width; |
| 306 | + let target_width = max_width.saturating_sub(ellipsis_width); |
| 307 | + |
| 308 | + if target_width == 0 { |
| 309 | + return Ok(ellipsis.to_string()); |
| 310 | + } |
| 311 | + |
| 312 | + // Find the longest prefix that fits |
| 313 | + let mut end = text.len(); |
| 314 | + for (i, _) in text.char_indices().rev() { |
| 315 | + let prefix = &text[..i]; |
| 316 | + let prefix_width = renderer.measure_text(prefix, style)?.width; |
| 317 | + if prefix_width <= target_width { |
| 318 | + end = i; |
| 319 | + break; |
| 320 | + } |
| 321 | + } |
| 322 | + |
| 323 | + if end == 0 { |
| 324 | + Ok(ellipsis.to_string()) |
| 325 | + } else { |
| 326 | + Ok(format!("{}{}", &text[..end], ellipsis)) |
| 327 | + } |
| 328 | + } |
| 329 | + |
| 330 | + /// Render tooltip showing full filter text as multiline. |
| 331 | + fn render_tooltip(&self, renderer: &Renderer, text: &str) -> Result<()> { |
| 332 | + let theme = renderer.theme(); |
| 333 | + |
| 334 | + let tooltip_style = TextStyle::new() |
| 335 | + .font_family(&theme.font_family) |
| 336 | + .font_size(theme.font_size - 2.0) |
| 337 | + .color(theme.foreground); |
| 338 | + |
| 339 | + // Parse filter patterns and format as multiline |
| 340 | + // Remove "Filter: " prefix if present |
| 341 | + let filter_text = text.strip_prefix("Filter: ").unwrap_or(text); |
| 342 | + |
| 343 | + // Split by semicolon or comma and clean up patterns |
| 344 | + let patterns: Vec<&str> = filter_text |
| 345 | + .split(|c| c == ';' || c == ',') |
| 346 | + .map(|s| s.trim()) |
| 347 | + .filter(|s| !s.is_empty()) |
| 348 | + .collect(); |
| 349 | + |
| 350 | + // Format into columns (4 patterns per row max) |
| 351 | + let cols = 4; |
| 352 | + let mut lines: Vec<String> = Vec::new(); |
| 353 | + lines.push("Accepted file types:".to_string()); |
| 354 | + |
| 355 | + for chunk in patterns.chunks(cols) { |
| 356 | + let line = chunk.join(" "); |
| 357 | + lines.push(line); |
| 358 | + } |
| 359 | + |
| 360 | + // Measure dimensions |
| 361 | + let padding = 10u32; |
| 362 | + let line_height = (theme.font_size - 2.0) as u32 + 4; |
| 363 | + let mut max_width = 0u32; |
| 364 | + |
| 365 | + for line in &lines { |
| 366 | + let metrics = renderer.measure_text(line, &tooltip_style)?; |
| 367 | + max_width = max_width.max(metrics.width); |
| 368 | + } |
| 369 | + |
| 370 | + let tooltip_width = max_width + padding * 2; |
| 371 | + let tooltip_height = (lines.len() as u32 * line_height) + padding * 2; |
| 372 | + |
| 373 | + // Position tooltip above the filter area, but keep on screen |
| 374 | + let tooltip_x = self.filter_bounds.x.max(4); |
| 375 | + let tooltip_y = self.bounds.y - tooltip_height as i32 - 4; |
| 376 | + |
| 377 | + let tooltip_bounds = Rect::new(tooltip_x, tooltip_y, tooltip_width, tooltip_height); |
| 378 | + |
| 379 | + // Background with border and shadow effect |
| 380 | + let shadow_bounds = Rect::new(tooltip_x + 2, tooltip_y + 2, tooltip_width, tooltip_height); |
| 381 | + renderer.fill_rounded_rect(shadow_bounds, 6.0, theme.background.with_alpha(0.3))?; |
| 382 | + renderer.fill_rounded_rect(tooltip_bounds, 6.0, theme.background)?; |
| 383 | + renderer.stroke_rounded_rect(tooltip_bounds, 6.0, theme.border, 1.0)?; |
| 384 | + |
| 385 | + // Render each line |
| 386 | + let mut y = tooltip_y + padding as i32; |
| 387 | + for (i, line) in lines.iter().enumerate() { |
| 388 | + let style = if i == 0 { |
| 389 | + // Header line slightly brighter |
| 390 | + TextStyle::new() |
| 391 | + .font_family(&theme.font_family) |
| 392 | + .font_size(theme.font_size - 2.0) |
| 393 | + .color(theme.foreground) |
| 394 | + } else { |
| 395 | + tooltip_style.clone() |
| 396 | + }; |
| 397 | + |
| 398 | + renderer.text( |
| 399 | + line, |
| 400 | + (tooltip_x + padding as i32) as f64, |
| 401 | + y as f64, |
| 402 | + &style, |
| 403 | + )?; |
| 404 | + y += line_height as i32; |
| 405 | + } |
| 406 | + |
| 407 | + Ok(()) |
| 408 | + } |
| 241 | 409 | } |