@@ -15,6 +15,12 @@ const BUTTON_WIDTH: u32 = 100; |
| 15 | /// Button height. | 15 | /// Button height. |
| 16 | const BUTTON_HEIGHT: u32 = 28; | 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 | /// Picker toolbar click result. | 24 | /// Picker toolbar click result. |
| 19 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 20 | pub enum PickerToolbarClick { | 26 | pub enum PickerToolbarClick { |
@@ -38,13 +44,17 @@ pub struct PickerToolbar { |
| 38 | accept_bounds: Rect, | 44 | accept_bounds: Rect, |
| 39 | /// Cancel button bounds. | 45 | /// Cancel button bounds. |
| 40 | cancel_bounds: Rect, | 46 | cancel_bounds: Rect, |
| | 47 | + /// Filter text bounds (for hover detection). |
| | 48 | + filter_bounds: Rect, |
| 41 | /// Hovered button (0 = accept, 1 = cancel). | 49 | /// Hovered button (0 = accept, 1 = cancel). |
| 42 | hovered: Option<usize>, | 50 | hovered: Option<usize>, |
| | 51 | + /// Whether filter text is hovered. |
| | 52 | + filter_hovered: bool, |
| 43 | /// Focused button for keyboard navigation (0 = accept, 1 = cancel). | 53 | /// Focused button for keyboard navigation (0 = accept, 1 = cancel). |
| 44 | focused: usize, | 54 | focused: usize, |
| 45 | /// Whether accept button is enabled (has valid selection). | 55 | /// Whether accept button is enabled (has valid selection). |
| 46 | accept_enabled: bool, | 56 | accept_enabled: bool, |
| 47 | - /// Filter description shown in toolbar. | 57 | + /// Filter description shown in toolbar (full text). |
| 48 | filter_description: Option<String>, | 58 | filter_description: Option<String>, |
| 49 | } | 59 | } |
| 50 | | 60 | |
@@ -57,35 +67,43 @@ impl PickerToolbar { |
| 57 | cancel_label: "Cancel".to_string(), | 67 | cancel_label: "Cancel".to_string(), |
| 58 | accept_bounds: Rect::default(), | 68 | accept_bounds: Rect::default(), |
| 59 | cancel_bounds: Rect::default(), | 69 | cancel_bounds: Rect::default(), |
| | 70 | + filter_bounds: Rect::default(), |
| 60 | hovered: None, | 71 | hovered: None, |
| | 72 | + filter_hovered: false, |
| 61 | focused: 0, | 73 | focused: 0, |
| 62 | accept_enabled: false, | 74 | accept_enabled: false, |
| 63 | filter_description: None, | 75 | filter_description: None, |
| 64 | }; | 76 | }; |
| 65 | - toolbar.layout_buttons(); | 77 | + toolbar.layout(); |
| 66 | toolbar | 78 | toolbar |
| 67 | } | 79 | } |
| 68 | | 80 | |
| 69 | /// Set bounds. | 81 | /// Set bounds. |
| 70 | pub fn set_bounds(&mut self, bounds: Rect) { | 82 | pub fn set_bounds(&mut self, bounds: Rect) { |
| 71 | self.bounds = bounds; | 83 | self.bounds = bounds; |
| 72 | - self.layout_buttons(); | 84 | + self.layout(); |
| 73 | } | 85 | } |
| 74 | | 86 | |
| 75 | - /// Layout buttons. | 87 | + /// Layout buttons and filter area. |
| 76 | - fn layout_buttons(&mut self) { | 88 | + fn layout(&mut self) { |
| 77 | - // Buttons are right-aligned | | |
| 78 | - let padding = 8; | | |
| 79 | - let button_gap = 12; | | |
| 80 | - | | |
| 81 | // Cancel button (rightmost) | 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 | let button_y = self.bounds.y + (self.bounds.height as i32 - BUTTON_HEIGHT as i32) / 2; | 91 | let button_y = self.bounds.y + (self.bounds.height as i32 - BUTTON_HEIGHT as i32) / 2; |
| 84 | self.cancel_bounds = Rect::new(cancel_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT); | 92 | self.cancel_bounds = Rect::new(cancel_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT); |
| 85 | | 93 | |
| 86 | // Accept button (to the left of cancel) | 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 | self.accept_bounds = Rect::new(accept_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT); | 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 | /// Set whether accept button is enabled. | 109 | /// Set whether accept button is enabled. |
@@ -100,12 +118,18 @@ impl PickerToolbar { |
| 100 | | 118 | |
| 101 | /// Handle mouse move. Returns true if hovered state changed. | 119 | /// Handle mouse move. Returns true if hovered state changed. |
| 102 | pub fn on_mouse_move(&mut self, pos: Point) -> bool { | 120 | pub fn on_mouse_move(&mut self, pos: Point) -> bool { |
| | 121 | + let mut changed = false; |
| | 122 | + |
| 103 | if !self.bounds.contains_point(pos) { | 123 | if !self.bounds.contains_point(pos) { |
| 104 | if self.hovered.is_some() { | 124 | if self.hovered.is_some() { |
| 105 | self.hovered = None; | 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 | let new_hovered = if self.accept_bounds.contains_point(pos) { | 135 | let new_hovered = if self.accept_bounds.contains_point(pos) { |
@@ -116,11 +140,35 @@ impl PickerToolbar { |
| 116 | None | 140 | None |
| 117 | }; | 141 | }; |
| 118 | | 142 | |
| 119 | - let changed = new_hovered != self.hovered; | 143 | + if new_hovered != self.hovered { |
| 120 | - self.hovered = new_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 | changed | 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 | /// Handle click. Returns the action if a button was clicked. | 172 | /// Handle click. Returns the action if a button was clicked. |
| 125 | pub fn on_click(&self, pos: Point) -> PickerToolbarClick { | 173 | pub fn on_click(&self, pos: Point) -> PickerToolbarClick { |
| 126 | if self.accept_bounds.contains_point(pos) && self.accept_enabled { | 174 | if self.accept_bounds.contains_point(pos) && self.accept_enabled { |
@@ -160,19 +208,24 @@ impl PickerToolbar { |
| 160 | // Toolbar background | 208 | // Toolbar background |
| 161 | renderer.fill_rect(self.bounds, theme.item_background)?; | 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 | if let Some(desc) = &self.filter_description { | 212 | if let Some(desc) = &self.filter_description { |
| 165 | let text_style = TextStyle::new() | 213 | let text_style = TextStyle::new() |
| 166 | .font_family(&theme.font_family) | 214 | .font_family(&theme.font_family) |
| 167 | .font_size(theme.font_size - 1.0) | 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( | 218 | + let max_width = self.max_filter_width() as f64; |
| 171 | - desc, | 219 | + let display_text = self.truncate_with_ellipsis(desc, max_width, renderer, &text_style)?; |
| 172 | - (self.bounds.x + 12) as f64, | 220 | + |
| 173 | - (self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2) as f64, | 221 | + let text_x = (self.bounds.x + PADDING + 4) as f64; |
| 174 | - &text_style, | 222 | + let text_y = (self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2) as f64; |
| 175 | - )?; | 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 | // Accept button | 231 | // Accept button |
@@ -238,4 +291,119 @@ impl PickerToolbar { |
| 238 | | 291 | |
| 239 | Ok(()) | 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 | } |