gardesk/garfield / f8dfd93

Browse files

ui: add inline rename (F2) with text field overlay

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f8dfd93be5ee381de6f180feffa0bfb7968291c0
Parents
c649f9a
Tree
96ded0c

6 changed files

StatusFile+-
M garfield/src/app.rs 61 6
M garfield/src/ui/column_view.rs 5 0
M garfield/src/ui/grid_view.rs 84 28
M garfield/src/ui/list_view.rs 116 17
M garfield/src/ui/mod.rs 1 1
M garfield/src/ui/tab.rs 210 4
garfield/src/app.rsmodified
@@ -613,6 +613,31 @@ impl App {
613613
             }
614614
         }
615615
 
616
+        // Handle rename input
617
+        if self.is_renaming() {
618
+            match key {
619
+                Key::Escape => {
620
+                    self.cancel_rename();
621
+                    return;
622
+                }
623
+                Key::Return => {
624
+                    self.confirm_rename();
625
+                    return;
626
+                }
627
+                _ => {
628
+                    // Route other keys to rename handler
629
+                    if let Some(pane) = self.focused_pane_mut() {
630
+                        if let Some(tab) = pane.active_tab_mut() {
631
+                            if tab.handle_rename_key(key) {
632
+                                return;
633
+                            }
634
+                        }
635
+                    }
636
+                }
637
+            }
638
+            return;
639
+        }
640
+
616641
         // Alt+Arrow for history navigation
617642
         if modifiers.alt {
618643
             match key {
@@ -1288,13 +1313,43 @@ impl App {
12881313
 
12891314
     /// Start inline rename for the selected file.
12901315
     fn start_rename(&mut self) {
1291
-        // TODO: Implement inline rename UI
1292
-        // For now, just log that rename was requested
1293
-        if let Some(entry) = self.focused_pane()
1316
+        if let Some(pane) = self.focused_pane_mut() {
1317
+            if let Some(tab) = pane.active_tab_mut() {
1318
+                tab.start_rename();
1319
+            }
1320
+        }
1321
+    }
1322
+
1323
+    /// Check if rename is in progress.
1324
+    fn is_renaming(&self) -> bool {
1325
+        self.focused_pane()
12941326
             .and_then(|p| p.active_tab())
1295
-            .and_then(|t| t.selected_entry())
1296
-        {
1297
-            eprintln!("Rename requested for: {}", entry.name);
1327
+            .map_or(false, |t| t.is_renaming())
1328
+    }
1329
+
1330
+    /// Cancel rename operation.
1331
+    fn cancel_rename(&mut self) {
1332
+        if let Some(pane) = self.focused_pane_mut() {
1333
+            if let Some(tab) = pane.active_tab_mut() {
1334
+                tab.cancel_rename();
1335
+            }
1336
+        }
1337
+    }
1338
+
1339
+    /// Confirm rename operation.
1340
+    fn confirm_rename(&mut self) {
1341
+        let result = self.focused_pane_mut()
1342
+            .and_then(|p| p.active_tab_mut())
1343
+            .map(|t| t.confirm_rename());
1344
+
1345
+        match result {
1346
+            Some(Ok(new_name)) => {
1347
+                self.status_bar.set_status_message(format!("Renamed to '{}'", new_name));
1348
+            }
1349
+            Some(Err(msg)) => {
1350
+                self.status_bar.set_status_message(format!("Rename failed: {}", msg));
1351
+            }
1352
+            None => {}
12981353
         }
12991354
     }
13001355
 
garfield/src/ui/column_view.rsmodified
@@ -210,6 +210,11 @@ impl ColumnView {
210210
         visible.get(self.current_column.selected).copied()
211211
     }
212212
 
213
+    /// Get the focused index.
214
+    pub fn focused_index(&self) -> usize {
215
+        self.current_column.selected
216
+    }
217
+
213218
     /// Get all selected entries.
214219
     pub fn selected_entries(&self) -> Vec<&FileEntry> {
215220
         let visible = self.visible_entries();
garfield/src/ui/grid_view.rsmodified
@@ -1,6 +1,7 @@
11
 //! Grid/icon view for displaying directory contents.
22
 
33
 use crate::core::{EntryType, FileEntry, SortDirection, SortOrder};
4
+use crate::ui::tab::RenameState;
45
 use gartk_core::{Color, Modifiers, Point, Rect};
56
 use gartk_render::{Renderer, TextAlign, TextStyle};
67
 use std::collections::HashSet;
@@ -164,6 +165,11 @@ impl GridView {
164165
         visible.get(self.focused).copied()
165166
     }
166167
 
168
+    /// Get the focused index.
169
+    pub fn focused_index(&self) -> usize {
170
+        self.focused
171
+    }
172
+
167173
     /// Get all selected entries.
168174
     pub fn selected_entries(&self) -> Vec<&FileEntry> {
169175
         let visible = self.visible_entries();
@@ -473,7 +479,7 @@ impl GridView {
473479
     }
474480
 
475481
     /// Render the grid view.
476
-    pub fn render(&self, renderer: &Renderer) -> anyhow::Result<()> {
482
+    pub fn render(&self, renderer: &Renderer, rename_state: Option<&RenameState>) -> anyhow::Result<()> {
477483
         let theme = renderer.theme();
478484
         let visible = self.visible_entries();
479485
         let visible_rows = self.visible_rows();
@@ -496,6 +502,7 @@ impl GridView {
496502
             let is_selected = self.selected.contains(&i);
497503
             let is_focused = i == self.focused;
498504
             let is_hovered = self.hovered == Some(i);
505
+            let is_renaming = rename_state.map_or(false, |s| s.index == i);
499506
 
500507
             // Cell background
501508
             if is_selected {
@@ -542,27 +549,6 @@ impl GridView {
542549
             let icon_center_y = cell.y + 10 + (icon_font_size / 2.0) as i32;
543550
             renderer.text_centered(icon, Point::new(icon_center_x, icon_center_y), &icon_style)?;
544551
 
545
-            // File name (truncated)
546
-            let name_color = if is_selected {
547
-                theme.selection_foreground
548
-            } else if entry.hidden {
549
-                theme.item_foreground.with_alpha(0.5)
550
-            } else {
551
-                theme.item_foreground
552
-            };
553
-
554
-            let name_font_size = self.icon_size.font_size();
555
-            let name_style = TextStyle::new()
556
-                .font_family(&theme.font_family)
557
-                .font_size(name_font_size)
558
-                .color(name_color);
559
-
560
-            // Use Pango CENTER alignment for proper text centering (like Dolphin/Nautilus)
561
-            let name_style = name_style.clone()
562
-                .align(TextAlign::Center)
563
-                .ellipsize(true)
564
-                .max_width((cell.width - 8) as i32);
565
-
566552
             // Rectangle for the text area below the icon
567553
             let icon_size = self.icon_size.icon_size();
568554
             let text_rect = Rect::new(
@@ -571,13 +557,42 @@ impl GridView {
571557
                 cell.width - 8,
572558
                 cell.height - icon_size - 12,
573559
             );
574
-            // Add "@" suffix for symlinks
575
-            let display_name = if entry.is_symlink {
576
-                format!("{}@", entry.name)
560
+
561
+            if is_renaming {
562
+                // Render rename text field
563
+                if let Some(state) = rename_state {
564
+                    self.render_rename_field(renderer, text_rect, state)?;
565
+                }
577566
             } else {
578
-                entry.name.clone()
579
-            };
580
-            renderer.text_in_rect(&display_name, text_rect, &name_style)?;
567
+                // File name (truncated)
568
+                let name_color = if is_selected {
569
+                    theme.selection_foreground
570
+                } else if entry.hidden {
571
+                    theme.item_foreground.with_alpha(0.5)
572
+                } else {
573
+                    theme.item_foreground
574
+                };
575
+
576
+                let name_font_size = self.icon_size.font_size();
577
+                let name_style = TextStyle::new()
578
+                    .font_family(&theme.font_family)
579
+                    .font_size(name_font_size)
580
+                    .color(name_color);
581
+
582
+                // Use Pango CENTER alignment for proper text centering (like Dolphin/Nautilus)
583
+                let name_style = name_style.clone()
584
+                    .align(TextAlign::Center)
585
+                    .ellipsize(true)
586
+                    .max_width((cell.width - 8) as i32);
587
+
588
+                // Add "@" suffix for symlinks
589
+                let display_name = if entry.is_symlink {
590
+                    format!("{}@", entry.name)
591
+                } else {
592
+                    entry.name.clone()
593
+                };
594
+                renderer.text_in_rect(&display_name, text_rect, &name_style)?;
595
+            }
581596
         }
582597
 
583598
         // Draw rubber band selection rectangle
@@ -591,6 +606,47 @@ impl GridView {
591606
         Ok(())
592607
     }
593608
 
609
+    /// Render the inline rename text field.
610
+    fn render_rename_field(&self, renderer: &Renderer, rect: Rect, state: &RenameState) -> anyhow::Result<()> {
611
+        let theme = renderer.theme();
612
+
613
+        // Background for text field
614
+        let field_rect = Rect::new(rect.x, rect.y, rect.width, 20);
615
+        renderer.fill_rounded_rect(field_rect, 2.0, theme.background)?;
616
+        renderer.stroke_rounded_rect(field_rect, 2.0, theme.selection_background, 1.0)?;
617
+
618
+        // Text style
619
+        let text_style = TextStyle::new()
620
+            .font_family(&theme.font_family)
621
+            .font_size(self.icon_size.font_size())
622
+            .color(theme.foreground);
623
+
624
+        // Draw the text (centered)
625
+        let text_width = renderer.measure_text(&state.text, &text_style)?.width;
626
+        let text_x = field_rect.x + (field_rect.width as i32 - text_width as i32) / 2;
627
+        let text_y = field_rect.y + 2;
628
+        renderer.text(&state.text, text_x as f64, text_y as f64, &text_style)?;
629
+
630
+        // Draw cursor
631
+        let cursor_x = if state.cursor == 0 {
632
+            text_x as f64
633
+        } else {
634
+            let prefix = &state.text[..state.cursor];
635
+            let prefix_width = renderer.measure_text(prefix, &text_style)?.width;
636
+            text_x as f64 + prefix_width as f64
637
+        };
638
+        renderer.line(
639
+            cursor_x,
640
+            (field_rect.y + 2) as f64,
641
+            cursor_x,
642
+            (field_rect.y + field_rect.height as i32 - 2) as f64,
643
+            theme.foreground,
644
+            1.0,
645
+        )?;
646
+
647
+        Ok(())
648
+    }
649
+
594650
     /// Get placeholder icon for file extension.
595651
     fn file_icon_for_extension(ext: Option<&str>) -> &'static str {
596652
         match ext {
garfield/src/ui/list_view.rsmodified
@@ -1,6 +1,7 @@
11
 //! List view component for displaying directory contents.
22
 
33
 use crate::core::{EntryType, FileEntry, SortDirection, SortOrder};
4
+use crate::ui::tab::RenameState;
45
 use gartk_core::{Color, Modifiers, Point, Rect};
56
 use gartk_render::{Renderer, TextStyle};
67
 use std::collections::HashSet;
@@ -109,6 +110,11 @@ impl ListView {
109110
         visible.get(self.focused).copied()
110111
     }
111112
 
113
+    /// Get the focused index.
114
+    pub fn focused_index(&self) -> usize {
115
+        self.focused
116
+    }
117
+
112118
     /// Get all selected entries.
113119
     pub fn selected_entries(&self) -> Vec<&FileEntry> {
114120
         let visible = self.visible_entries();
@@ -490,7 +496,7 @@ impl ListView {
490496
     }
491497
 
492498
     /// Render the list view.
493
-    pub fn render(&self, renderer: &Renderer) -> anyhow::Result<()> {
499
+    pub fn render(&self, renderer: &Renderer, rename_state: Option<&RenameState>) -> anyhow::Result<()> {
494500
         let theme = renderer.theme();
495501
         let visible = self.visible_entries();
496502
         let visible_rows = self.visible_rows();
@@ -512,6 +518,7 @@ impl ListView {
512518
             let actual_index = self.scroll_offset + i;
513519
             let is_selected = self.selected.contains(&actual_index);
514520
             let is_focused = actual_index == self.focused;
521
+            let is_renaming = rename_state.map_or(false, |s| s.index == actual_index);
515522
 
516523
             // Row background
517524
             if is_selected {
@@ -560,26 +567,35 @@ impl ListView {
560567
                 EntryType::Symlink => "\u{1F517} ",
561568
                 _ => "\u{1F4C4} ",
562569
             };
563
-            let display_name = if entry.is_symlink {
564
-                if let Some(target) = &entry.symlink_target {
565
-                    let target_str = target.to_string_lossy();
566
-                    // Truncate long targets
567
-                    let target_display = if target_str.len() > 30 {
568
-                        format!("...{}", &target_str[target_str.len()-27..])
570
+
571
+            let name_rect =
572
+                Rect::new(row_rect.x + 8, row_rect.y, self.column_widths[0], ROW_HEIGHT);
573
+
574
+            if is_renaming {
575
+                // Render rename text field
576
+                if let Some(state) = rename_state {
577
+                    self.render_rename_field(renderer, name_rect, state, &icon)?;
578
+                }
579
+            } else {
580
+                let display_name = if entry.is_symlink {
581
+                    if let Some(target) = &entry.symlink_target {
582
+                        let target_str = target.to_string_lossy();
583
+                        // Truncate long targets
584
+                        let target_display = if target_str.len() > 30 {
585
+                            format!("...{}", &target_str[target_str.len()-27..])
586
+                        } else {
587
+                            target_str.to_string()
588
+                        };
589
+                        format!("{}{} -> {}", icon, entry.name, target_display)
569590
                     } else {
570
-                        target_str.to_string()
571
-                    };
572
-                    format!("{}{} -> {}", icon, entry.name, target_display)
591
+                        format!("{}{}", icon, entry.name)
592
+                    }
573593
                 } else {
574594
                     format!("{}{}", icon, entry.name)
575
-                }
576
-            } else {
577
-                format!("{}{}", icon, entry.name)
578
-            };
595
+                };
579596
 
580
-            let name_rect =
581
-                Rect::new(row_rect.x + 8, row_rect.y, self.column_widths[0], ROW_HEIGHT);
582
-            renderer.text_in_rect(&display_name, name_rect, &name_style)?;
597
+                renderer.text_in_rect(&display_name, name_rect, &name_style)?;
598
+            }
583599
 
584600
             // Size
585601
             let size_rect = Rect::new(
@@ -603,6 +619,89 @@ impl ListView {
603619
         Ok(())
604620
     }
605621
 
622
+    /// Render the inline rename text field.
623
+    fn render_rename_field(&self, renderer: &Renderer, rect: Rect, state: &RenameState, icon: &str) -> anyhow::Result<()> {
624
+        let theme = renderer.theme();
625
+
626
+        // Background for text field (slightly lighter)
627
+        let field_rect = Rect::new(
628
+            rect.x + 24, // After icon
629
+            rect.y + 2,
630
+            rect.width.saturating_sub(28),
631
+            rect.height - 4,
632
+        );
633
+        renderer.fill_rounded_rect(field_rect, 2.0, theme.background)?;
634
+        renderer.stroke_rounded_rect(field_rect, 2.0, theme.selection_background, 1.0)?;
635
+
636
+        // Draw icon
637
+        let icon_style = TextStyle::new()
638
+            .font_family(&theme.font_family)
639
+            .font_size(theme.font_size)
640
+            .color(theme.item_foreground);
641
+        renderer.text(icon, (rect.x + 4) as f64, (rect.y + 4) as f64, &icon_style)?;
642
+
643
+        // Text style for the editable text
644
+        let text_style = TextStyle::new()
645
+            .font_family(&theme.font_family)
646
+            .font_size(theme.font_size)
647
+            .color(theme.foreground);
648
+
649
+        // Draw the text
650
+        let text_x = field_rect.x + 4;
651
+        let text_y = field_rect.y + 3;
652
+        renderer.text(&state.text, text_x as f64, text_y as f64, &text_style)?;
653
+
654
+        // Draw cursor
655
+        let cursor_x = if state.cursor == 0 {
656
+            text_x as f64
657
+        } else {
658
+            let prefix = &state.text[..state.cursor];
659
+            let prefix_width = renderer.measure_text(prefix, &text_style)?.width;
660
+            text_x as f64 + prefix_width as f64
661
+        };
662
+        renderer.line(
663
+            cursor_x,
664
+            (field_rect.y + 2) as f64,
665
+            cursor_x,
666
+            (field_rect.y + field_rect.height as i32 - 2) as f64,
667
+            theme.foreground,
668
+            1.0,
669
+        )?;
670
+
671
+        // Draw selection highlight if any
672
+        if let Some(sel_start) = state.selection_start {
673
+            let (from, to) = if sel_start < state.cursor {
674
+                (sel_start, state.cursor)
675
+            } else {
676
+                (state.cursor, sel_start)
677
+            };
678
+
679
+            let from_x = if from == 0 {
680
+                text_x as f64
681
+            } else {
682
+                let prefix = &state.text[..from];
683
+                text_x as f64 + renderer.measure_text(prefix, &text_style)?.width as f64
684
+            };
685
+
686
+            let to_x = if to == 0 {
687
+                text_x as f64
688
+            } else {
689
+                let prefix = &state.text[..to];
690
+                text_x as f64 + renderer.measure_text(prefix, &text_style)?.width as f64
691
+            };
692
+
693
+            let sel_rect = Rect::new(
694
+                from_x as i32,
695
+                field_rect.y + 2,
696
+                (to_x - from_x) as u32,
697
+                field_rect.height - 4,
698
+            );
699
+            renderer.fill_rect(sel_rect, theme.selection_background.with_alpha(0.3))?;
700
+        }
701
+
702
+        Ok(())
703
+    }
704
+
606705
     /// Render column headers.
607706
     fn render_header(&self, renderer: &Renderer) -> anyhow::Result<()> {
608707
         let theme = renderer.theme();
garfield/src/ui/mod.rsmodified
@@ -22,6 +22,6 @@ pub use list_view::ListView;
2222
 pub use pane::Pane;
2323
 pub use sidebar::Sidebar;
2424
 pub use status_bar::StatusBar;
25
-pub use tab::{Tab, ViewMode};
25
+pub use tab::{RenameState, Tab, ViewMode};
2626
 pub use tab_bar::{TabBar, TabInfo, TAB_BAR_HEIGHT};
2727
 pub use toolbar::{Toolbar, ToolbarAction, TOOLBAR_HEIGHT};
garfield/src/ui/tab.rsmodified
@@ -1,11 +1,26 @@
11
 //! Tab state for a single directory view.
22
 
3
-use crate::core::{read_directory, sort_entries, FileEntry, History, SortDirection, SortOrder};
3
+use crate::core::{read_directory, rename_path, sort_entries, FileEntry, History, SortDirection, SortOrder};
44
 use crate::ui::{ColumnClickResult, ColumnView, GridView, ListView};
5
-use gartk_core::{Modifiers, Point, Rect};
5
+use gartk_core::{Key, Modifiers, Point, Rect};
66
 use gartk_render::Renderer;
77
 use std::path::PathBuf;
88
 
9
+/// State for inline rename operation.
10
+#[derive(Debug, Clone)]
11
+pub struct RenameState {
12
+    /// Index of the entry being renamed.
13
+    pub index: usize,
14
+    /// Original filename (for cancel).
15
+    pub original: String,
16
+    /// Current edited text.
17
+    pub text: String,
18
+    /// Cursor position in the text.
19
+    pub cursor: usize,
20
+    /// Selection start (for text selection).
21
+    pub selection_start: Option<usize>,
22
+}
23
+
924
 /// View mode for displaying files.
1025
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1126
 pub enum ViewMode {
@@ -46,6 +61,8 @@ pub struct Tab {
4661
     entries: Vec<FileEntry>,
4762
     /// Tab bounds (for rendering).
4863
     bounds: Rect,
64
+    /// Active rename operation (if any).
65
+    renaming: Option<RenameState>,
4966
 }
5067
 
5168
 impl Tab {
@@ -76,6 +93,7 @@ impl Tab {
7693
             sort_direction: SortDirection::Ascending,
7794
             entries,
7895
             bounds,
96
+            renaming: None,
7997
         }
8098
     }
8199
 
@@ -405,13 +423,201 @@ impl Tab {
405423
         self.grid_view.stop_drag();
406424
     }
407425
 
426
+    // === Rename operations ===
427
+
428
+    /// Check if rename is in progress.
429
+    pub fn is_renaming(&self) -> bool {
430
+        self.renaming.is_some()
431
+    }
432
+
433
+    /// Get the current rename state.
434
+    pub fn rename_state(&self) -> Option<&RenameState> {
435
+        self.renaming.as_ref()
436
+    }
437
+
438
+    /// Start renaming the selected entry.
439
+    pub fn start_rename(&mut self) {
440
+        let (index, name) = match self.view_mode {
441
+            ViewMode::List => {
442
+                if let Some(entry) = self.list_view.selected_entry() {
443
+                    (self.list_view.focused_index(), entry.name.clone())
444
+                } else {
445
+                    return;
446
+                }
447
+            }
448
+            ViewMode::Grid => {
449
+                if let Some(entry) = self.grid_view.selected_entry() {
450
+                    (self.grid_view.focused_index(), entry.name.clone())
451
+                } else {
452
+                    return;
453
+                }
454
+            }
455
+            ViewMode::Columns => {
456
+                if let Some(entry) = self.column_view.selected_entry() {
457
+                    (self.column_view.focused_index(), entry.name.clone())
458
+                } else {
459
+                    return;
460
+                }
461
+            }
462
+        };
463
+
464
+        // Select all text initially (cursor at end, selection from start)
465
+        let len = name.len();
466
+        self.renaming = Some(RenameState {
467
+            index,
468
+            original: name.clone(),
469
+            text: name,
470
+            cursor: len,
471
+            selection_start: Some(0),
472
+        });
473
+    }
474
+
475
+    /// Cancel rename operation.
476
+    pub fn cancel_rename(&mut self) {
477
+        self.renaming = None;
478
+    }
479
+
480
+    /// Confirm rename operation. Returns Ok(new_name) on success, Err(message) on failure.
481
+    pub fn confirm_rename(&mut self) -> Result<String, String> {
482
+        let state = match self.renaming.take() {
483
+            Some(s) => s,
484
+            None => return Err("No rename in progress".to_string()),
485
+        };
486
+
487
+        let new_name = state.text.trim();
488
+
489
+        // Validate: non-empty and different from original
490
+        if new_name.is_empty() {
491
+            return Err("Name cannot be empty".to_string());
492
+        }
493
+
494
+        if new_name == state.original {
495
+            // No change, just cancel silently
496
+            return Ok(state.original);
497
+        }
498
+
499
+        // Validate: no path separators
500
+        if new_name.contains('/') || new_name.contains('\\') {
501
+            return Err("Name cannot contain path separators".to_string());
502
+        }
503
+
504
+        // Get the entry path
505
+        let visible = self.visible_entries();
506
+        let entry = visible.get(state.index).ok_or("Entry not found")?;
507
+        let entry_path = entry.path.clone();
508
+
509
+        // Perform the rename
510
+        match rename_path(&entry_path, new_name) {
511
+            Ok(_) => {
512
+                self.refresh();
513
+                Ok(new_name.to_string())
514
+            }
515
+            Err(e) => Err(e.to_string()),
516
+        }
517
+    }
518
+
519
+    /// Handle keyboard input during rename. Returns true if handled.
520
+    pub fn handle_rename_key(&mut self, key: &Key) -> bool {
521
+        let state = match self.renaming.as_mut() {
522
+            Some(s) => s,
523
+            None => return false,
524
+        };
525
+
526
+        match key {
527
+            Key::Escape => {
528
+                self.cancel_rename();
529
+                true
530
+            }
531
+            Key::Return => {
532
+                // Will be handled by caller to show result
533
+                true
534
+            }
535
+            Key::Backspace => {
536
+                if state.selection_start.is_some() {
537
+                    // Delete selection
538
+                    state.delete_selection();
539
+                } else if state.cursor > 0 {
540
+                    state.cursor -= 1;
541
+                    state.text.remove(state.cursor);
542
+                }
543
+                true
544
+            }
545
+            Key::Delete => {
546
+                if state.selection_start.is_some() {
547
+                    state.delete_selection();
548
+                } else if state.cursor < state.text.len() {
549
+                    state.text.remove(state.cursor);
550
+                }
551
+                true
552
+            }
553
+            Key::Left => {
554
+                state.selection_start = None;
555
+                if state.cursor > 0 {
556
+                    state.cursor -= 1;
557
+                }
558
+                true
559
+            }
560
+            Key::Right => {
561
+                state.selection_start = None;
562
+                if state.cursor < state.text.len() {
563
+                    state.cursor += 1;
564
+                }
565
+                true
566
+            }
567
+            Key::Home => {
568
+                state.selection_start = None;
569
+                state.cursor = 0;
570
+                true
571
+            }
572
+            Key::End => {
573
+                state.selection_start = None;
574
+                state.cursor = state.text.len();
575
+                true
576
+            }
577
+            Key::Char(c) => {
578
+                // Clear selection first
579
+                if state.selection_start.is_some() {
580
+                    state.delete_selection();
581
+                }
582
+                state.text.insert(state.cursor, *c);
583
+                state.cursor += 1;
584
+                true
585
+            }
586
+            _ => false,
587
+        }
588
+    }
589
+
590
+    /// Get visible entries (helper for rename).
591
+    fn visible_entries(&self) -> Vec<&FileEntry> {
592
+        match self.view_mode {
593
+            ViewMode::List => self.list_view.visible_entries(),
594
+            ViewMode::Grid => self.grid_view.visible_entries(),
595
+            ViewMode::Columns => self.column_view.visible_entries(),
596
+        }
597
+    }
598
+
408599
     // === Rendering ===
409600
 
410601
     pub fn render(&self, renderer: &Renderer) -> anyhow::Result<()> {
411602
         match self.view_mode {
412
-            ViewMode::List => self.list_view.render(renderer),
413
-            ViewMode::Grid => self.grid_view.render(renderer),
603
+            ViewMode::List => self.list_view.render(renderer, self.renaming.as_ref()),
604
+            ViewMode::Grid => self.grid_view.render(renderer, self.renaming.as_ref()),
414605
             ViewMode::Columns => self.column_view.render(renderer),
415606
         }
416607
     }
417608
 }
609
+
610
+impl RenameState {
611
+    /// Delete selected text (if any).
612
+    fn delete_selection(&mut self) {
613
+        if let Some(start) = self.selection_start.take() {
614
+            let (from, to) = if start < self.cursor {
615
+                (start, self.cursor)
616
+            } else {
617
+                (self.cursor, start)
618
+            };
619
+            self.text.drain(from..to);
620
+            self.cursor = from;
621
+        }
622
+    }
623
+}