gardesk/garfield / af05392

Browse files

ui: add breadcrumb truncation for long paths

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
af0539224f60c4e9c094da4e0f69494fc8c47437
Parents
3651dd8
Tree
a727aa2

7 changed files

StatusFile+-
M garfield/src/app.rs 138 5
M garfield/src/ui/breadcrumb.rs 74 7
M garfield/src/ui/column_view.rs 87 15
M garfield/src/ui/grid_view.rs 19 0
M garfield/src/ui/list_view.rs 14 0
M garfield/src/ui/sidebar.rs 40 0
M garfield/src/ui/tab.rs 9 0
garfield/src/app.rsmodified
@@ -4,7 +4,7 @@ use garfield::ui::pane::SplitDirection;
44
 use garfield::ui::{AddressBar, Breadcrumb, HelpModal, Pane, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
55
 use anyhow::Result;
66
 use gartk_core::{InputEvent, Key, Point, Rect, Theme};
7
-use gartk_render::{Renderer, Surface};
7
+use gartk_render::{Renderer, Surface, TextStyle};
88
 use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig};
99
 use std::path::PathBuf;
1010
 use std::time::Instant;
@@ -55,6 +55,16 @@ pub struct App {
5555
     last_click_time: Option<Instant>,
5656
     /// Last click position for double-click detection.
5757
     last_click_pos: Option<Point>,
58
+    /// Path being dragged for bookmark drop (directory only).
59
+    drag_source_path: Option<PathBuf>,
60
+    /// Name of the item being dragged (for visual feedback).
61
+    drag_label: Option<String>,
62
+    /// Starting position of potential drag.
63
+    drag_start_pos: Option<Point>,
64
+    /// Current mouse position during drag (for visual feedback).
65
+    drag_current_pos: Option<Point>,
66
+    /// Whether drag is actively in progress (moved past threshold).
67
+    drag_active: bool,
5868
 }
5969
 
6070
 impl App {
@@ -182,6 +192,11 @@ impl App {
182192
             pane_resize_path: None,
183193
             last_click_time: None,
184194
             last_click_pos: None,
195
+            drag_source_path: None,
196
+            drag_label: None,
197
+            drag_start_pos: None,
198
+            drag_current_pos: None,
199
+            drag_active: false,
185200
         };
186201
 
187202
         app.update_status_bar();
@@ -217,8 +232,9 @@ impl App {
217232
                     self.handle_mouse_press(pos, &mouse_event.modifiers);
218233
                     ev.request_redraw();
219234
                 }
220
-                InputEvent::MouseRelease(_) => {
221
-                    self.handle_mouse_release();
235
+                InputEvent::MouseRelease(mouse_event) => {
236
+                    let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
237
+                    self.handle_mouse_release(pos);
222238
                     ev.request_redraw();
223239
                 }
224240
                 InputEvent::MouseMove(mouse_event) => {
@@ -363,11 +379,43 @@ impl App {
363379
                     tab.on_click(pos, modifiers);
364380
                 }
365381
             }
382
+
383
+            // Capture drag source from entry at click position (for bookmark drag)
384
+            let entry_at_click = self.focused_pane()
385
+                .and_then(|pane| pane.active_tab())
386
+                .and_then(|tab| tab.entry_at_point(pos))
387
+                .filter(|e| e.is_dir())
388
+                .map(|e| (e.path.clone(), e.name.clone()));
389
+
390
+            if let Some((path, name)) = entry_at_click {
391
+                self.drag_source_path = Some(path);
392
+                self.drag_label = Some(name);
393
+                self.drag_start_pos = Some(pos);
394
+                self.drag_current_pos = Some(pos);
395
+                self.drag_active = false;
396
+            }
366397
         }
367398
     }
368399
 
369400
     /// Handle mouse release.
370
-    fn handle_mouse_release(&mut self) {
401
+    fn handle_mouse_release(&mut self, pos: Point) {
402
+        // Handle bookmark drag drop
403
+        if self.drag_active {
404
+            if let Some(path) = self.drag_source_path.take() {
405
+                if self.sidebar.is_bookmark_drop_zone(pos) {
406
+                    self.sidebar.add_bookmark(&path);
407
+                }
408
+            }
409
+        }
410
+
411
+        // Clear drag state
412
+        self.drag_source_path = None;
413
+        self.drag_label = None;
414
+        self.drag_start_pos = None;
415
+        self.drag_current_pos = None;
416
+        self.drag_active = false;
417
+        self.sidebar.set_drop_highlight(false);
418
+
371419
         // Clear pane resize
372420
         self.pane_resize_path = None;
373421
 
@@ -400,6 +448,26 @@ impl App {
400448
             }
401449
         }
402450
 
451
+        // Handle bookmark drag in progress
452
+        if self.drag_source_path.is_some() {
453
+            // Update current drag position for visual feedback
454
+            self.drag_current_pos = Some(pos);
455
+
456
+            // Check if we've moved past the drag threshold (5px)
457
+            if let Some(start_pos) = self.drag_start_pos {
458
+                let distance = ((pos.x - start_pos.x).pow(2) + (pos.y - start_pos.y).pow(2)) as f64;
459
+                if distance.sqrt() > 5.0 {
460
+                    self.drag_active = true;
461
+                }
462
+            }
463
+
464
+            // Update sidebar drop highlight if drag is active
465
+            if self.drag_active {
466
+                let is_over_drop_zone = self.sidebar.is_bookmark_drop_zone(pos);
467
+                self.sidebar.set_drop_highlight(is_over_drop_zone);
468
+            }
469
+        }
470
+
403471
         self.toolbar.on_mouse_move(pos);
404472
         self.breadcrumb.on_mouse_move(pos);
405473
         self.sidebar.on_mouse_move(pos);
@@ -563,7 +631,15 @@ impl App {
563631
 
564632
         match key {
565633
             Key::Escape => {
566
-                if self.address_bar.is_active() {
634
+                // Cancel drag first if active
635
+                if self.drag_active || self.drag_source_path.is_some() {
636
+                    self.drag_source_path = None;
637
+                    self.drag_label = None;
638
+                    self.drag_start_pos = None;
639
+                    self.drag_current_pos = None;
640
+                    self.drag_active = false;
641
+                    self.sidebar.set_drop_highlight(false);
642
+                } else if self.address_bar.is_active() {
567643
                     self.address_bar.cancel();
568644
                 } else {
569645
                     self.should_quit = true;
@@ -1097,6 +1173,9 @@ impl App {
10971173
         // Draw toolbar tooltip overlay (on top of other UI)
10981174
         self.toolbar.render_tooltip_overlay(&self.renderer)?;
10991175
 
1176
+        // Draw drag label overlay (on top of other UI)
1177
+        self.render_drag_label()?;
1178
+
11001179
         // Draw help modal overlay (on top of everything)
11011180
         self.help_modal.render(&self.renderer)?;
11021181
 
@@ -1107,6 +1186,60 @@ impl App {
11071186
         Ok(())
11081187
     }
11091188
 
1189
+    /// Render the drag label overlay when dragging a folder.
1190
+    fn render_drag_label(&self) -> Result<()> {
1191
+        // Only render if drag is active and we have a label
1192
+        if !self.drag_active {
1193
+            return Ok(());
1194
+        }
1195
+
1196
+        let (label, pos) = match (&self.drag_label, self.drag_current_pos) {
1197
+            (Some(label), Some(pos)) => (label, pos),
1198
+            _ => return Ok(()),
1199
+        };
1200
+
1201
+        let theme = self.renderer.theme();
1202
+
1203
+        // Create text style for the drag label
1204
+        let text_style = TextStyle::new()
1205
+            .font_family(&theme.font_family)
1206
+            .font_size(theme.font_size)
1207
+            .color(theme.item_foreground);
1208
+
1209
+        // Measure the text to size the background
1210
+        let text_size = self.renderer.measure_text(label, &text_style)?;
1211
+
1212
+        // Position the label slightly offset from the cursor
1213
+        let label_x = pos.x + 16;
1214
+        let label_y = pos.y + 8;
1215
+        let padding = 8;
1216
+
1217
+        // Draw background with rounded appearance
1218
+        let bg_rect = Rect::new(
1219
+            label_x - padding,
1220
+            label_y - padding / 2,
1221
+            text_size.width + (padding * 2) as u32,
1222
+            text_size.height + padding as u32,
1223
+        );
1224
+
1225
+        // Semi-transparent dark background
1226
+        let bg_color = gartk_core::Color::from_u8(40, 40, 45, 230);
1227
+        self.renderer.fill_rect(bg_rect, bg_color)?;
1228
+
1229
+        // Border
1230
+        let border_color = theme.selection_background.with_alpha(0.8);
1231
+        self.renderer.stroke_rect(bg_rect, border_color, 1.0)?;
1232
+
1233
+        // Folder icon prefix
1234
+        let icon_style = text_style.clone().color(theme.selection_background);
1235
+        self.renderer.text("*", label_x as f64, label_y as f64, &icon_style)?;
1236
+
1237
+        // Draw the text
1238
+        self.renderer.text(label, (label_x + 14) as f64, label_y as f64, &text_style)?;
1239
+
1240
+        Ok(())
1241
+    }
1242
+
11101243
     /// Blit the rendered surface to the window.
11111244
     fn blit_surface(&mut self) -> Result<()> {
11121245
         let size = self.renderer.size();
garfield/src/ui/breadcrumb.rsmodified
@@ -166,25 +166,92 @@ impl Breadcrumb {
166166
         renderer.text(">", (self.bounds.x + 8 + button_width) as f64, button_y as f64, &forward_style)?;
167167
 
168168
         // Start position for path segments (after buttons)
169
-        let mut x = self.bounds.x + 8 + button_width * 2 + 8;
169
+        let start_x = self.bounds.x + 8 + button_width * 2 + 8;
170170
         let text_y = self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2;
171
+        let available_width = (self.bounds.x + self.bounds.width as i32 - start_x - 16) as u32;
171172
 
172173
         // Check if first segment is root "/" for separator logic
173174
         let first_is_root = self.segments.first().map(|s| s.text == "/").unwrap_or(false);
174175
 
175
-        // Measure and render segments
176
-        for (i, segment) in self.segments.iter_mut().enumerate() {
177
-            // Add separator before non-root segments (but not after root "/")
176
+        // Measure total width and individual segment widths
177
+        let ellipsis = "...";
178
+        let ellipsis_size = renderer.measure_text(ellipsis, &separator_style)?;
179
+        let sep_size = renderer.measure_text(&self.separator, &separator_style)?;
180
+
181
+        let mut segment_widths: Vec<u32> = Vec::new();
182
+        let mut total_width: u32 = 0;
183
+
184
+        for (i, segment) in self.segments.iter().enumerate() {
185
+            let seg_width = renderer.measure_text(&segment.text, &style)?.width + 4;
186
+            segment_widths.push(seg_width);
187
+
188
+            // Add separator width
178189
             if i > 0 {
179
-                // If previous segment was root "/", just add space, not " / "
190
+                let sep_w = if i == 1 && first_is_root { 8 } else { sep_size.width };
191
+                total_width += sep_w;
192
+            }
193
+            total_width += seg_width;
194
+        }
195
+
196
+        // Determine which segments to skip (truncate from left)
197
+        let mut skip_count = 0;
198
+        let mut show_ellipsis = false;
199
+
200
+        if total_width > available_width && self.segments.len() > 2 {
201
+            // Need to truncate - always show at least root and last segment
202
+            let mut running_width = ellipsis_size.width + sep_size.width; // "... / "
203
+
204
+            // Start from the end and work backwards to find how many we can show
205
+            let mut can_show_from = self.segments.len();
206
+            for i in (1..self.segments.len()).rev() {
207
+                let seg_width = segment_widths[i] + sep_size.width;
208
+                if running_width + seg_width <= available_width {
209
+                    running_width += seg_width;
210
+                    can_show_from = i;
211
+                } else {
212
+                    break;
213
+                }
214
+            }
215
+
216
+            if can_show_from > 1 {
217
+                skip_count = can_show_from - 1; // Skip segments 1 to can_show_from-1 (keep root)
218
+                show_ellipsis = true;
219
+            }
220
+        }
221
+
222
+        // Render segments
223
+        let mut x = start_x;
224
+
225
+        for (i, segment) in self.segments.iter_mut().enumerate() {
226
+            // Skip truncated segments (but always show root at index 0)
227
+            if i > 0 && i <= skip_count {
228
+                // Clear bounds for skipped segments
229
+                segment.bounds = Rect::new(0, 0, 0, 0);
230
+                continue;
231
+            }
232
+
233
+            // Add ellipsis after root if truncating
234
+            if show_ellipsis && i == skip_count + 1 {
235
+                let sep = if first_is_root { " " } else { &self.separator };
236
+                let sep_w = renderer.measure_text(sep, &separator_style)?;
237
+                renderer.text(sep, x as f64, text_y as f64, &separator_style)?;
238
+                x += sep_w.width as i32;
239
+
240
+                renderer.text(ellipsis, x as f64, text_y as f64, &separator_style)?;
241
+                x += ellipsis_size.width as i32;
242
+
243
+                renderer.text(&self.separator, x as f64, text_y as f64, &separator_style)?;
244
+                x += sep_size.width as i32;
245
+            } else if i > 0 && !(show_ellipsis && i == skip_count + 1) {
246
+                // Normal separator
180247
                 let sep = if i == 1 && first_is_root {
181248
                     " "
182249
                 } else {
183250
                     &self.separator
184251
                 };
185
-                let sep_size = renderer.measure_text(sep, &separator_style)?;
252
+                let sep_w = renderer.measure_text(sep, &separator_style)?;
186253
                 renderer.text(sep, x as f64, text_y as f64, &separator_style)?;
187
-                x += sep_size.width as i32;
254
+                x += sep_w.width as i32;
188255
             }
189256
 
190257
             // Measure segment
garfield/src/ui/column_view.rsmodified
@@ -23,6 +23,17 @@ pub enum ColumnClickResult {
2323
     None,
2424
 }
2525
 
26
+/// Role of a column for rendering purposes.
27
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28
+enum ColumnRole {
29
+    /// Parent column - dimmed position indicator.
30
+    Parent,
31
+    /// Current column - active selection highlight.
32
+    Current,
33
+    /// Preview column - no selection highlight.
34
+    Preview,
35
+}
36
+
2637
 /// A single column in the Miller columns view.
2738
 struct Column {
2839
     /// Entries in this column.
@@ -368,6 +379,60 @@ impl ColumnView {
368379
         }
369380
     }
370381
 
382
+    /// Get the entry at the given position (for drag detection).
383
+    pub fn entry_at_point(&self, pos: Point) -> Option<&FileEntry> {
384
+        // Check current column (main selection column)
385
+        if self.current_column.bounds.contains_point(pos) {
386
+            let visible = self.visible_entries();
387
+            let visible_rows = self.current_column.visible_rows();
388
+
389
+            for i in self.current_column.scroll_offset..(self.current_column.scroll_offset + visible_rows).min(visible.len()) {
390
+                let y = self.current_column.bounds.y + ((i - self.current_column.scroll_offset) as i32 * ROW_HEIGHT as i32);
391
+                let row = Rect::new(self.current_column.bounds.x, y, self.current_column.bounds.width, ROW_HEIGHT);
392
+
393
+                if row.contains_point(pos) {
394
+                    return visible.get(i).copied();
395
+                }
396
+            }
397
+        }
398
+
399
+        // Check parent column
400
+        if let Some(ref parent) = self.parent_column {
401
+            if parent.bounds.contains_point(pos) {
402
+                let visible = parent.visible_entries(self.show_hidden);
403
+                let visible_rows = parent.visible_rows();
404
+
405
+                for i in parent.scroll_offset..(parent.scroll_offset + visible_rows).min(visible.len()) {
406
+                    let y = parent.bounds.y + ((i - parent.scroll_offset) as i32 * ROW_HEIGHT as i32);
407
+                    let row = Rect::new(parent.bounds.x, y, parent.bounds.width, ROW_HEIGHT);
408
+
409
+                    if row.contains_point(pos) {
410
+                        return visible.get(i).copied();
411
+                    }
412
+                }
413
+            }
414
+        }
415
+
416
+        // Check preview column
417
+        if let Some(ref preview) = self.preview_column {
418
+            if preview.bounds.contains_point(pos) {
419
+                let visible = preview.visible_entries(self.show_hidden);
420
+                let visible_rows = preview.visible_rows();
421
+
422
+                for i in preview.scroll_offset..(preview.scroll_offset + visible_rows).min(visible.len()) {
423
+                    let y = preview.bounds.y + ((i - preview.scroll_offset) as i32 * ROW_HEIGHT as i32);
424
+                    let row = Rect::new(preview.bounds.x, y, preview.bounds.width, ROW_HEIGHT);
425
+
426
+                    if row.contains_point(pos) {
427
+                        return visible.get(i).copied();
428
+                    }
429
+                }
430
+            }
431
+        }
432
+
433
+        None
434
+    }
435
+
371436
     /// Handle click in any column. Returns click result.
372437
     pub fn on_click(&mut self, pos: Point, modifiers: &Modifiers) -> ColumnClickResult {
373438
         // Check parent column click - navigate to clicked directory
@@ -477,9 +542,9 @@ impl ColumnView {
477542
     pub fn render(&self, renderer: &Renderer) -> anyhow::Result<()> {
478543
         let theme = renderer.theme();
479544
 
480
-        // Draw parent column
545
+        // Draw parent column (with dimmed position indicator)
481546
         if let Some(ref parent) = self.parent_column {
482
-            self.render_column(renderer, parent, false)?;
547
+            self.render_column(renderer, parent, ColumnRole::Parent)?;
483548
             // Draw divider
484549
             let divider_x = parent.bounds.x + parent.bounds.width as i32;
485550
             renderer.line(
@@ -492,10 +557,10 @@ impl ColumnView {
492557
             )?;
493558
         }
494559
 
495
-        // Draw current column
496
-        self.render_column(renderer, &self.current_column, true)?;
560
+        // Draw current column (with active selection highlight)
561
+        self.render_column(renderer, &self.current_column, ColumnRole::Current)?;
497562
 
498
-        // Draw preview column
563
+        // Draw preview column (no selection highlight)
499564
         if let Some(ref preview) = self.preview_column {
500565
             let divider_x = self.current_column.bounds.x + self.current_column.bounds.width as i32;
501566
             renderer.line(
@@ -506,7 +571,7 @@ impl ColumnView {
506571
                 theme.border,
507572
                 1.0,
508573
             )?;
509
-            self.render_column(renderer, preview, false)?;
574
+            self.render_column(renderer, preview, ColumnRole::Preview)?;
510575
         } else if let Some(entry) = self.selected_entry() {
511576
             // Show file info for non-directories
512577
             if !entry.is_dir() {
@@ -518,7 +583,7 @@ impl ColumnView {
518583
     }
519584
 
520585
     /// Render a single column.
521
-    fn render_column(&self, renderer: &Renderer, column: &Column, is_current: bool) -> anyhow::Result<()> {
586
+    fn render_column(&self, renderer: &Renderer, column: &Column, role: ColumnRole) -> anyhow::Result<()> {
522587
         let theme = renderer.theme();
523588
         let visible = column.visible_entries(self.show_hidden);
524589
         let visible_rows = column.visible_rows();
@@ -532,22 +597,29 @@ impl ColumnView {
532597
             let y = column.bounds.y + (i as i32 * ROW_HEIGHT as i32);
533598
             let row = Rect::new(column.bounds.x, y, column.bounds.width, ROW_HEIGHT);
534599
 
535
-            let is_selected = if is_current {
536
-                self.selected.contains(&actual_index)
537
-            } else {
538
-                actual_index == column.selected
600
+            // Determine selection state based on column role
601
+            let (is_selected, show_highlight) = match role {
602
+                ColumnRole::Current => (self.selected.contains(&actual_index), true),
603
+                ColumnRole::Parent => (actual_index == column.selected, true),
604
+                ColumnRole::Preview => (false, false), // No highlight in preview
539605
             };
540606
             let is_hovered = column.hovered == Some(actual_index);
541607
 
542608
             // Row background
543
-            if is_selected {
544
-                renderer.fill_rect(row, theme.item_selected_background)?;
609
+            if is_selected && show_highlight {
610
+                if role == ColumnRole::Parent {
611
+                    // Dimmer highlight for parent column position indicator
612
+                    renderer.fill_rect(row, theme.item_selected_background.with_alpha(0.4))?;
613
+                } else {
614
+                    // Full highlight for current column
615
+                    renderer.fill_rect(row, theme.item_selected_background)?;
616
+                }
545617
             } else if is_hovered {
546618
                 renderer.fill_rect(row, theme.item_background)?;
547619
             }
548620
 
549
-            // Entry color
550
-            let text_color = if is_selected {
621
+            // Entry color - don't use selection foreground for parent column
622
+            let text_color = if is_selected && show_highlight && role == ColumnRole::Current {
551623
                 theme.selection_foreground
552624
             } else if entry.hidden {
553625
                 theme.item_foreground.with_alpha(0.5)
garfield/src/ui/grid_view.rsmodified
@@ -316,6 +316,25 @@ impl GridView {
316316
         }
317317
     }
318318
 
319
+    /// Get the entry at the given position (for drag detection).
320
+    pub fn entry_at_point(&self, pos: Point) -> Option<&FileEntry> {
321
+        if !self.bounds.contains_point(pos) {
322
+            return None;
323
+        }
324
+
325
+        let visible = self.visible_entries();
326
+        let start_index = self.scroll_offset * self.columns;
327
+        let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len());
328
+
329
+        for i in start_index..end_index {
330
+            if self.cell_bounds(i).contains_point(pos) {
331
+                return visible.get(i).copied();
332
+            }
333
+        }
334
+
335
+        None
336
+    }
337
+
319338
     /// Handle cell click. Returns index of clicked cell if valid.
320339
     pub fn on_click(&mut self, pos: Point, modifiers: &Modifiers) -> Option<usize> {
321340
         if !self.bounds.contains_point(pos) {
garfield/src/ui/list_view.rsmodified
@@ -420,6 +420,20 @@ impl ListView {
420420
         None
421421
     }
422422
 
423
+    /// Get the entry at the given position (for drag detection).
424
+    pub fn entry_at_point(&self, pos: Point) -> Option<&FileEntry> {
425
+        let content = self.content_bounds();
426
+        if !content.contains_point(pos) {
427
+            return None;
428
+        }
429
+
430
+        let relative_y = pos.y - content.y;
431
+        let row_index = self.scroll_offset + (relative_y / ROW_HEIGHT as i32) as usize;
432
+
433
+        let visible = self.visible_entries();
434
+        visible.get(row_index).copied()
435
+    }
436
+
423437
     /// Handle row click. Returns index of clicked row if valid.
424438
     pub fn on_row_click(&mut self, pos: Point, modifiers: &Modifiers) -> Option<usize> {
425439
         let content = self.content_bounds();
garfield/src/ui/sidebar.rsmodified
@@ -39,6 +39,10 @@ pub struct Sidebar {
3939
     padding: u32,
4040
     /// Path to bookmarks file.
4141
     bookmarks_path: PathBuf,
42
+    /// Y coordinate where bookmarks section starts (for drop zone detection).
43
+    bookmarks_section_y: i32,
44
+    /// Whether to show drop highlight on bookmarks section.
45
+    drop_highlight: bool,
4246
 }
4347
 
4448
 impl Sidebar {
@@ -58,6 +62,8 @@ impl Sidebar {
5862
             item_height: 28,
5963
             padding: 8,
6064
             bookmarks_path,
65
+            bookmarks_section_y: 0,
66
+            drop_highlight: false,
6167
         };
6268
         sidebar.populate_default_places();
6369
         sidebar.load_bookmarks();
@@ -373,6 +379,26 @@ impl Sidebar {
373379
         self.hovered = None;
374380
     }
375381
 
382
+    /// Check if the given position is within the bookmarks drop zone.
383
+    pub fn is_bookmark_drop_zone(&self, pos: Point) -> bool {
384
+        if !self.visible {
385
+            return false;
386
+        }
387
+
388
+        // Check if within sidebar bounds
389
+        if !self.bounds.contains_point(pos) {
390
+            return false;
391
+        }
392
+
393
+        // Check if below the bookmarks section start
394
+        pos.y >= self.bookmarks_section_y
395
+    }
396
+
397
+    /// Set whether to show the drop highlight on the bookmarks section.
398
+    pub fn set_drop_highlight(&mut self, highlight: bool) {
399
+        self.drop_highlight = highlight;
400
+    }
401
+
376402
     /// Render the sidebar.
377403
     pub fn render(&mut self, renderer: &Renderer) -> anyhow::Result<()> {
378404
         if !self.visible {
@@ -435,6 +461,20 @@ impl Sidebar {
435461
         )?;
436462
         y += 8;
437463
 
464
+        // Store the bookmarks section start for drop zone detection
465
+        self.bookmarks_section_y = y;
466
+
467
+        // Draw drop highlight if active
468
+        if self.drop_highlight {
469
+            let highlight_rect = Rect::new(
470
+                self.bounds.x,
471
+                y,
472
+                self.bounds.width,
473
+                (self.bounds.y + self.bounds.height as i32 - y) as u32,
474
+            );
475
+            renderer.fill_rect(highlight_rect, theme.selection_background.with_alpha(0.2))?;
476
+        }
477
+
438478
         // Header
439479
         let header_x = self.bounds.x + self.padding as i32;
440480
         renderer.text("Bookmarks", header_x as f64, y as f64, &header_style)?;
garfield/src/ui/tab.rsmodified
@@ -206,6 +206,15 @@ impl Tab {
206206
         }
207207
     }
208208
 
209
+    /// Get the entry at the given position (for drag detection).
210
+    pub fn entry_at_point(&self, pos: Point) -> Option<&FileEntry> {
211
+        match self.view_mode {
212
+            ViewMode::List => self.list_view.entry_at_point(pos),
213
+            ViewMode::Grid => self.grid_view.entry_at_point(pos),
214
+            ViewMode::Columns => self.column_view.entry_at_point(pos),
215
+        }
216
+    }
217
+
209218
     /// Enter the selected entry (open directory).
210219
     pub fn enter_selected(&mut self) {
211220
         if let Some(entry) = self.selected_entry().cloned() {