gardesk/garfield / 9f89f23

Browse files

grid: optimize rubber band selection, fix status bar update

- throttle render requests during drag to ~60fps
- add visible_indices cache to avoid repeated allocations
- pre-compute colors and font sizes outside render loop
- call final selection update in stop_drag before clearing state
- add select_by_path to Tab for selecting renamed files
- add lightweight status bar update for drag operations
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9f89f2331c86e3d06840cac90b322c731f4cebe2
Parents
cbd3758
Tree
38726f3

4 changed files

StatusFile+-
M garfield/src/app.rs 44 27
M garfield/src/ui/grid_view.rs 117 48
M garfield/src/ui/status_bar.rs 5 0
M garfield/src/ui/tab.rs 23 0
garfield/src/app.rsmodified
@@ -9,7 +9,7 @@ use garfield::ui::pane::SplitDirection;
99
 use garfield::ui::{AddressBar, AppPickerDialog, AppPickerResult, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, ContextMenu, ContextMenuAction, ContextType, DialogResult, HelpModal, InputDialog, InputResult, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
1010
 use anyhow::Result;
1111
 use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme};
12
-use gartk_render::{Renderer, Surface, TextStyle};
12
+use gartk_render::{Renderer, TextStyle};
1313
 use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig};
1414
 use std::path::PathBuf;
1515
 use std::time::Instant;
@@ -567,6 +567,8 @@ impl App {
567567
                     tab.on_click(pos, modifiers);
568568
                 }
569569
             }
570
+            // Update status bar with new selection
571
+            self.update_status_bar();
570572
 
571573
             // Capture drag source from entry at click position (for bookmark drag)
572574
             let entry_at_click = self.focused_pane()
@@ -632,6 +634,8 @@ impl App {
632634
         self.pane_resize_path = None;
633635
         self.sidebar_resizing = false;
634636
 
637
+        let was_dragging = self.focused_pane().map(|p| p.is_dragging()).unwrap_or(false);
638
+
635639
         if let Some(pane) = self.focused_pane_mut() {
636640
             if pane.is_resizing() {
637641
                 pane.stop_resize();
@@ -640,6 +644,11 @@ impl App {
640644
                 pane.stop_drag();
641645
             }
642646
         }
647
+
648
+        // Update status bar after any mouse release (ensures rubber band selection is reflected)
649
+        if was_dragging {
650
+            self.update_status_bar();
651
+        }
643652
     }
644653
 
645654
     /// Handle mouse move. Returns true if a redraw is needed.
@@ -745,12 +754,23 @@ impl App {
745754
         needs_redraw |= self.sidebar.on_mouse_move(pos);
746755
         needs_redraw |= self.tab_bar.on_mouse_move(pos);
747756
 
757
+        let mut is_dragging = false;
758
+        let mut selection_count = 0;
748759
         if let Some(pane) = self.focused_pane_mut() {
749760
             if let Some(tab) = pane.active_tab_mut() {
750761
                 needs_redraw |= tab.on_mouse_move(pos);
762
+                is_dragging = tab.is_dragging();
763
+                if is_dragging {
764
+                    selection_count = tab.selection_count();
765
+                }
751766
             }
752767
         }
753768
 
769
+        // Update status bar selection count if rubber band is active (lightweight update)
770
+        if is_dragging {
771
+            self.status_bar.update_selection_count(selection_count);
772
+        }
773
+
754774
         needs_redraw
755775
     }
756776
 
@@ -2380,6 +2400,8 @@ impl App {
23802400
                     });
23812401
                 }
23822402
                 self.status_bar.set_status_message(format!("Renamed to '{}'", new_name));
2403
+                // Update status bar with new entry count
2404
+                self.update_status_bar();
23832405
             }
23842406
             Some(Err(msg)) => {
23852407
                 self.status_bar.set_status_message(format!("Rename failed: {}", msg));
@@ -2705,33 +2727,28 @@ impl App {
27052727
     /// Blit the rendered surface to the window.
27062728
     fn blit_surface(&mut self) -> Result<()> {
27072729
         let size = self.renderer.size();
2708
-        let conn = self.window.connection();
2709
-
2710
-        let ctx = self.renderer.context()?;
2711
-        ctx.target().flush();
2712
-
2713
-        let mut temp_surface = Surface::new(size.width, size.height)?;
2714
-        let temp_ctx = temp_surface.context()?;
2715
-        temp_ctx.set_source_surface(self.renderer.surface().cairo_surface(), 0.0, 0.0)?;
2716
-        temp_ctx.paint()?;
2717
-        drop(temp_ctx);
2718
-
2719
-        let data = temp_surface.data()?;
2720
-
2721
-        conn.inner().put_image(
2722
-            ImageFormat::Z_PIXMAP,
2723
-            self.window.id(),
2724
-            self.gc,
2725
-            size.width as u16,
2726
-            size.height as u16,
2727
-            0,
2728
-            0,
2729
-            0,
2730
-            self.window.depth(),
2731
-            &data,
2732
-        )?;
2730
+        let window_id = self.window.id();
2731
+        let depth = self.window.depth();
2732
+        let gc = self.gc;
2733
+        let conn = self.window.connection().clone();
2734
+
2735
+        // Access surface data directly without copying, blit to X11
2736
+        self.renderer.surface_mut().with_data(|data| {
2737
+            let _ = conn.inner().put_image(
2738
+                ImageFormat::Z_PIXMAP,
2739
+                window_id,
2740
+                gc,
2741
+                size.width as u16,
2742
+                size.height as u16,
2743
+                0,
2744
+                0,
2745
+                0,
2746
+                depth,
2747
+                data,
2748
+            );
2749
+        })?;
27332750
 
2734
-        conn.flush()?;
2751
+        self.window.connection().flush()?;
27352752
 
27362753
         Ok(())
27372754
     }
garfield/src/ui/grid_view.rsmodified
@@ -5,6 +5,7 @@ use crate::ui::tab::RenameState;
55
 use gartk_core::{Color, Modifiers, Point, Rect};
66
 use gartk_render::{Renderer, TextAlign, TextStyle};
77
 use std::collections::HashSet;
8
+use std::time::{Duration, Instant};
89
 
910
 /// Padding around cells.
1011
 pub const CELL_PADDING: u32 = 8;
@@ -69,6 +70,8 @@ impl IconSize {
6970
 pub struct GridView {
7071
     /// Entries to display.
7172
     entries: Vec<FileEntry>,
73
+    /// Cached indices of visible entries (respecting hidden filter).
74
+    visible_indices: Vec<usize>,
7275
     /// Currently focused index (for keyboard nav).
7376
     focused: usize,
7477
     /// Selected indices (for multi-select).
@@ -91,6 +94,10 @@ pub struct GridView {
9194
     drag_current: Option<Point>,
9295
     /// Icon size setting.
9396
     icon_size: IconSize,
97
+    /// Last time rubber band selection was updated (for throttling).
98
+    last_selection_update: Option<Instant>,
99
+    /// Last time we requested a redraw during drag (for frame rate limiting).
100
+    last_drag_render: Option<Instant>,
94101
 }
95102
 
96103
 impl GridView {
@@ -100,6 +107,7 @@ impl GridView {
100107
         let columns = Self::calculate_columns_for_size(bounds.width, icon_size);
101108
         Self {
102109
             entries: Vec::new(),
110
+            visible_indices: Vec::new(),
103111
             focused: 0,
104112
             selected: HashSet::new(),
105113
             selection_anchor: None,
@@ -111,9 +119,21 @@ impl GridView {
111119
             drag_start: None,
112120
             drag_current: None,
113121
             icon_size,
122
+            last_selection_update: None,
123
+            last_drag_render: None,
114124
         }
115125
     }
116126
 
127
+    /// Rebuild the visible indices cache.
128
+    fn rebuild_visible_cache(&mut self) {
129
+        self.visible_indices = self.entries
130
+            .iter()
131
+            .enumerate()
132
+            .filter(|(_, e)| self.show_hidden || !e.hidden)
133
+            .map(|(i, _)| i)
134
+            .collect();
135
+    }
136
+
117137
     /// Calculate number of columns that fit in the given width for a specific icon size.
118138
     fn calculate_columns_for_size(width: u32, icon_size: IconSize) -> usize {
119139
         let cell_size = icon_size.cell_size();
@@ -144,6 +164,7 @@ impl GridView {
144164
     /// Set the entries to display.
145165
     pub fn set_entries(&mut self, entries: Vec<FileEntry>) {
146166
         self.entries = entries;
167
+        self.rebuild_visible_cache();
147168
         self.focused = 0;
148169
         self.selected.clear();
149170
         self.selected.insert(0);
@@ -152,17 +173,29 @@ impl GridView {
152173
     }
153174
 
154175
     /// Get visible entries (respecting hidden filter).
176
+    /// Uses cached indices for efficiency.
155177
     pub fn visible_entries(&self) -> Vec<&FileEntry> {
156
-        self.entries
178
+        self.visible_indices
157179
             .iter()
158
-            .filter(|e| self.show_hidden || !e.hidden)
180
+            .filter_map(|&i| self.entries.get(i))
159181
             .collect()
160182
     }
161183
 
184
+    /// Get visible entry count without allocation.
185
+    #[inline]
186
+    pub fn visible_count(&self) -> usize {
187
+        self.visible_indices.len()
188
+    }
189
+
190
+    /// Get entry at visible index without allocation.
191
+    #[inline]
192
+    pub fn visible_entry(&self, visible_idx: usize) -> Option<&FileEntry> {
193
+        self.visible_indices.get(visible_idx).and_then(|&i| self.entries.get(i))
194
+    }
195
+
162196
     /// Get the currently focused entry.
163197
     pub fn selected_entry(&self) -> Option<&FileEntry> {
164
-        let visible = self.visible_entries();
165
-        visible.get(self.focused).copied()
198
+        self.visible_entry(self.focused)
166199
     }
167200
 
168201
     /// Get the focused index.
@@ -181,10 +214,9 @@ impl GridView {
181214
 
182215
     /// Get all selected entries.
183216
     pub fn selected_entries(&self) -> Vec<&FileEntry> {
184
-        let visible = self.visible_entries();
185217
         self.selected
186218
             .iter()
187
-            .filter_map(|&i| visible.get(i).copied())
219
+            .filter_map(|&i| self.visible_entry(i))
188220
             .collect()
189221
     }
190222
 
@@ -207,7 +239,8 @@ impl GridView {
207239
     /// Toggle hidden files visibility.
208240
     pub fn toggle_hidden(&mut self) {
209241
         self.show_hidden = !self.show_hidden;
210
-        let visible_count = self.visible_entries().len();
242
+        self.rebuild_visible_cache();
243
+        let visible_count = self.visible_count();
211244
         if self.focused >= visible_count && visible_count > 0 {
212245
             self.focused = visible_count - 1;
213246
         }
@@ -229,7 +262,7 @@ impl GridView {
229262
 
230263
     /// Move selection down (to next row).
231264
     pub fn select_next(&mut self) {
232
-        let visible_count = self.visible_entries().len();
265
+        let visible_count = self.visible_count();
233266
         if self.focused + self.columns < visible_count {
234267
             self.focused += self.columns;
235268
         } else if self.focused < visible_count.saturating_sub(1) {
@@ -248,7 +281,7 @@ impl GridView {
248281
 
249282
     /// Move selection right.
250283
     pub fn select_right(&mut self) {
251
-        let visible_count = self.visible_entries().len();
284
+        let visible_count = self.visible_count();
252285
         if self.focused + 1 < visible_count {
253286
             self.focused += 1;
254287
             self.update_single_selection();
@@ -283,7 +316,7 @@ impl GridView {
283316
 
284317
     /// Jump to last entry.
285318
     pub fn select_last(&mut self) {
286
-        let visible_count = self.visible_entries().len();
319
+        let visible_count = self.visible_count();
287320
         if visible_count > 0 {
288321
             self.focused = visible_count - 1;
289322
             self.update_single_selection();
@@ -303,7 +336,7 @@ impl GridView {
303336
 
304337
     /// Page down.
305338
     pub fn page_down(&mut self) {
306
-        let visible_count = self.visible_entries().len();
339
+        let visible_count = self.visible_count();
307340
         let page_size = self.visible_rows() * self.columns;
308341
         self.focused = (self.focused + page_size).min(visible_count.saturating_sub(1));
309342
         self.update_single_selection();
@@ -311,7 +344,7 @@ impl GridView {
311344
 
312345
     /// Select all entries (Ctrl+A).
313346
     pub fn select_all(&mut self) {
314
-        let visible_count = self.visible_entries().len();
347
+        let visible_count = self.visible_count();
315348
         self.selected = (0..visible_count).collect();
316349
     }
317350
 
@@ -344,8 +377,32 @@ impl GridView {
344377
         // Handle rubber band drag
345378
         if self.drag_start.is_some() {
346379
             self.drag_current = Some(pos);
347
-            self.update_rubber_band_selection();
348
-            return true; // Rubber band always needs redraw
380
+
381
+            // Throttle both selection updates AND render requests to ~60fps
382
+            let should_update = match self.last_selection_update {
383
+                Some(last) => last.elapsed() >= Duration::from_millis(16),
384
+                None => true,
385
+            };
386
+
387
+            if should_update {
388
+                self.update_rubber_band_selection();
389
+                self.last_selection_update = Some(Instant::now());
390
+                self.last_drag_render = Some(Instant::now());
391
+                return true; // Request redraw at throttled rate
392
+            }
393
+
394
+            // Also limit render requests independently (in case selection didn't change)
395
+            let should_render = match self.last_drag_render {
396
+                Some(last) => last.elapsed() >= Duration::from_millis(16),
397
+                None => true,
398
+            };
399
+
400
+            if should_render {
401
+                self.last_drag_render = Some(Instant::now());
402
+                return true;
403
+            }
404
+
405
+            return false; // Skip redraw, we're within throttle window
349406
         }
350407
 
351408
         let old_hovered = self.hovered;
@@ -355,9 +412,9 @@ impl GridView {
355412
             return self.hovered != old_hovered;
356413
         }
357414
 
358
-        let visible = self.visible_entries();
415
+        let visible_count = self.visible_count();
359416
         let start_index = self.scroll_offset * self.columns;
360
-        let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len());
417
+        let end_index = (start_index + self.visible_rows() * self.columns).min(visible_count);
361418
 
362419
         self.hovered = None;
363420
         for i in start_index..end_index {
@@ -375,6 +432,8 @@ impl GridView {
375432
         self.drag_start = Some(pos);
376433
         self.drag_current = Some(pos);
377434
         self.selected.clear();
435
+        self.last_selection_update = None;
436
+        self.last_drag_render = None;
378437
     }
379438
 
380439
     /// Check if rubber band drag is active.
@@ -384,8 +443,12 @@ impl GridView {
384443
 
385444
     /// Stop rubber band selection.
386445
     pub fn stop_drag(&mut self) {
446
+        // Final selection update before clearing drag state
447
+        self.update_rubber_band_selection();
387448
         self.drag_start = None;
388449
         self.drag_current = None;
450
+        self.last_selection_update = None;
451
+        self.last_drag_render = None;
389452
     }
390453
 
391454
     /// Get the rubber band rectangle (if dragging).
@@ -404,9 +467,9 @@ impl GridView {
404467
     /// Update selection based on rubber band rectangle.
405468
     fn update_rubber_band_selection(&mut self) {
406469
         if let Some(band) = self.rubber_band_rect() {
407
-            let visible = self.visible_entries();
470
+            let visible_count = self.visible_count();
408471
             let start_index = self.scroll_offset * self.columns;
409
-            let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len());
472
+            let end_index = (start_index + self.visible_rows() * self.columns).min(visible_count);
410473
 
411474
             self.selected.clear();
412475
             for i in start_index..end_index {
@@ -424,13 +487,13 @@ impl GridView {
424487
             return None;
425488
         }
426489
 
427
-        let visible = self.visible_entries();
490
+        let visible_count = self.visible_count();
428491
         let start_index = self.scroll_offset * self.columns;
429
-        let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len());
492
+        let end_index = (start_index + self.visible_rows() * self.columns).min(visible_count);
430493
 
431494
         for i in start_index..end_index {
432495
             if self.cell_bounds(i).contains_point(pos) {
433
-                return visible.get(i).copied();
496
+                return self.visible_entry(i);
434497
             }
435498
         }
436499
 
@@ -443,9 +506,9 @@ impl GridView {
443506
             return None;
444507
         }
445508
 
446
-        let visible = self.visible_entries();
509
+        let visible_count = self.visible_count();
447510
         let start_index = self.scroll_offset * self.columns;
448
-        let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len());
511
+        let end_index = (start_index + self.visible_rows() * self.columns).min(visible_count);
449512
 
450513
         for i in start_index..end_index {
451514
             if self.cell_bounds(i).contains_point(pos) {
@@ -499,14 +562,30 @@ impl GridView {
499562
     /// Render the grid view.
500563
     pub fn render(&self, renderer: &Renderer, rename_state: Option<&RenameState>) -> anyhow::Result<()> {
501564
         let theme = renderer.theme();
502
-        let visible = self.visible_entries();
565
+        let visible_count = self.visible_count();
503566
         let visible_rows = self.visible_rows();
504567
 
505568
         let start_index = self.scroll_offset * self.columns;
506
-        let end_index = (start_index + visible_rows * self.columns).min(visible.len());
569
+        let end_index = (start_index + visible_rows * self.columns).min(visible_count);
570
+
571
+        // Pre-compute colors to avoid parsing hex strings in the loop
572
+        let dir_color = Color::new(0.36, 0.62, 0.85, 1.0); // #5c9fd8
573
+        let symlink_color = Color::new(0.78, 0.47, 0.87, 1.0); // #c678dd
574
+
575
+        // Pre-compute icon font size
576
+        let icon_font_size = match self.icon_size {
577
+            IconSize::Small => 24.0,
578
+            IconSize::Medium => 32.0,
579
+            IconSize::Large => 48.0,
580
+        };
581
+        let name_font_size = self.icon_size.font_size();
582
+        let icon_size_px = self.icon_size.icon_size();
507583
 
508584
         for i in start_index..end_index {
509
-            let entry = visible[i];
585
+            let entry = match self.visible_entry(i) {
586
+                Some(e) => e,
587
+                None => continue,
588
+            };
510589
             let cell = self.cell_bounds(i);
511590
 
512591
             // Skip cells outside visible area
@@ -545,18 +624,12 @@ impl GridView {
545624
                 theme.selection_foreground
546625
             } else {
547626
                 match entry.entry_type {
548
-                    EntryType::Directory => Color::from_hex("#5c9fd8").unwrap_or(theme.item_foreground),
549
-                    EntryType::Symlink => Color::from_hex("#c678dd").unwrap_or(theme.item_foreground),
627
+                    EntryType::Directory => dir_color,
628
+                    EntryType::Symlink => symlink_color,
550629
                     _ => theme.item_foreground,
551630
                 }
552631
             };
553632
 
554
-            // Scale icon font size based on icon size setting
555
-            let icon_font_size = match self.icon_size {
556
-                IconSize::Small => 24.0,
557
-                IconSize::Medium => 32.0,
558
-                IconSize::Large => 48.0,
559
-            };
560633
             let icon_style = TextStyle::new()
561634
                 .font_family(&theme.font_family)
562635
                 .font_size(icon_font_size)
@@ -568,12 +641,11 @@ impl GridView {
568641
             renderer.text_centered(icon, Point::new(icon_center_x, icon_center_y), &icon_style)?;
569642
 
570643
             // Rectangle for the text area below the icon
571
-            let icon_size = self.icon_size.icon_size();
572644
             let text_rect = Rect::new(
573645
                 cell.x + 4,
574
-                cell.y + icon_size as i32 + 8,
646
+                cell.y + icon_size_px as i32 + 8,
575647
                 cell.width - 8,
576
-                cell.height - icon_size - 12,
648
+                cell.height - icon_size_px - 12,
577649
             );
578650
 
579651
             if is_renaming {
@@ -591,25 +663,22 @@ impl GridView {
591663
                     theme.item_foreground
592664
                 };
593665
 
594
-                let name_font_size = self.icon_size.font_size();
666
+                // Use Pango CENTER alignment for proper text centering
595667
                 let name_style = TextStyle::new()
596668
                     .font_family(&theme.font_family)
597669
                     .font_size(name_font_size)
598
-                    .color(name_color);
599
-
600
-                // Use Pango CENTER alignment for proper text centering (like Dolphin/Nautilus)
601
-                let name_style = name_style.clone()
670
+                    .color(name_color)
602671
                     .align(TextAlign::Center)
603672
                     .ellipsize(true)
604673
                     .max_width((cell.width - 8) as i32);
605674
 
606
-                // Add "@" suffix for symlinks
607
-                let display_name = if entry.is_symlink {
608
-                    format!("{}@", entry.name)
675
+                // Render text - avoid allocation for non-symlinks
676
+                if entry.is_symlink {
677
+                    let display_name = format!("{}@", entry.name);
678
+                    renderer.text_in_rect(&display_name, text_rect, &name_style)?;
609679
                 } else {
610
-                    entry.name.clone()
611
-                };
612
-                renderer.text_in_rect(&display_name, text_rect, &name_style)?;
680
+                    renderer.text_in_rect(&entry.name, text_rect, &name_style)?;
681
+                }
613682
             }
614683
         }
615684
 
garfield/src/ui/status_bar.rsmodified
@@ -52,6 +52,11 @@ impl StatusBar {
5252
         self.selected_size = selected_size;
5353
     }
5454
 
55
+    /// Light-weight update of just the selection count (for drag operations).
56
+    pub fn update_selection_count(&mut self, selected_count: usize) {
57
+        self.selected_count = selected_count;
58
+    }
59
+
5560
     /// Set the current view mode name.
5661
     pub fn set_view_mode(&mut self, mode: &str) {
5762
         self.view_mode = mode.to_string();
garfield/src/ui/tab.rsmodified
@@ -578,12 +578,35 @@ impl Tab {
578578
         match rename_path(&entry_path, new_name) {
579579
             Ok(new_path) => {
580580
                 self.refresh();
581
+                // Select the renamed file
582
+                self.select_by_path(&new_path);
581583
                 Ok((entry_path, new_path, new_name.to_string()))
582584
             }
583585
             Err(e) => Err(e.to_string()),
584586
         }
585587
     }
586588
 
589
+    /// Select a file by its path.
590
+    fn select_by_path(&mut self, path: &std::path::Path) {
591
+        let visible = self.visible_entries();
592
+        for (i, entry) in visible.iter().enumerate() {
593
+            if entry.path == path {
594
+                match self.view_mode {
595
+                    ViewMode::List => {
596
+                        self.list_view.set_focused(i);
597
+                    }
598
+                    ViewMode::Grid => {
599
+                        self.grid_view.set_focused(i);
600
+                    }
601
+                    ViewMode::Columns => {
602
+                        self.column_view.set_focused(i);
603
+                    }
604
+                }
605
+                return;
606
+            }
607
+        }
608
+    }
609
+
587610
     /// Handle keyboard input during rename. Returns true if handled.
588611
     pub fn handle_rename_key(&mut self, key: &Key) -> bool {
589612
         let state = match self.renaming.as_mut() {