gardesk/garfield / e90316a

Browse files

picker: use garfield-picker class, add filter tooltip

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e90316a5469a694f240ef536886fbc4ea6a6e148
Parents
6d17a19
Tree
04dd1b0

2 changed files

StatusFile+-
M garfield/src/app.rs 8 2
M garfield/src/ui/picker_toolbar.rs 191 23
garfield/src/app.rsmodified
@@ -152,10 +152,16 @@ impl App {
152
             "garfield".to_string()
152
             "garfield".to_string()
153
         };
153
         };
154
 
154
 
155
-        // Create window - use Dialog type for picker mode
155
+        // Create window - use Dialog type and different class for picker mode
156
+        let window_class = if picker_config.is_picker() {
157
+            "garfield-picker"
158
+        } else {
159
+            "garfield"
160
+        };
161
+
156
         let mut window_config = WindowConfig::default()
162
         let mut window_config = WindowConfig::default()
157
             .title(&title)
163
             .title(&title)
158
-            .class("garfield")
164
+            .class(window_class)
159
             .position(x, y)
165
             .position(x, y)
160
             .size(width, height)
166
             .size(width, height)
161
             .transparent(false);
167
             .transparent(false);
garfield/src/ui/picker_toolbar.rsmodified
@@ -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
 }