gardesk/garfield / e2f36bd

Browse files

ui: add right-click context menu with submenus and new file/duplicate actions

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e2f36bdbb373dfad83a316c75839227b48c6412b
Parents
3289b39
Tree
e156b6e

5 changed files

StatusFile+-
M Cargo.lock 3 3
M garfield/src/app.rs 264 2
A garfield/src/ui/context_menu.rs 912 0
M garfield/src/ui/mod.rs 2 0
M garfield/src/ui/tab.rs 10 0
Cargo.lockmodified
@@ -362,7 +362,7 @@ dependencies = [
362362
 
363363
 [[package]]
364364
 name = "gartk-core"
365
-version = "0.1.0"
365
+version = "0.3.0"
366366
 dependencies = [
367367
  "serde",
368368
  "thiserror",
@@ -370,7 +370,7 @@ dependencies = [
370370
 
371371
 [[package]]
372372
 name = "gartk-render"
373
-version = "0.1.0"
373
+version = "0.3.0"
374374
 dependencies = [
375375
  "cairo-rs",
376376
  "gartk-core",
@@ -384,7 +384,7 @@ dependencies = [
384384
 
385385
 [[package]]
386386
 name = "gartk-x11"
387
-version = "0.1.0"
387
+version = "0.3.0"
388388
 dependencies = [
389389
  "gartk-core",
390390
  "thiserror",
garfield/src/app.rsmodified
@@ -6,7 +6,7 @@ use garfield::core::{
66
     trash_files, restore_from_trash,
77
 };
88
 use garfield::ui::pane::SplitDirection;
9
-use garfield::ui::{AddressBar, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, DialogResult, HelpModal, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
9
+use garfield::ui::{AddressBar, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, ContextMenu, ContextMenuAction, ContextType, DialogResult, HelpModal, 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};
1212
 use gartk_render::{Renderer, Surface, TextStyle};
@@ -80,6 +80,8 @@ pub struct App {
8080
     conflict_dialog: ConflictDialog,
8181
     /// Progress dialog for long operations.
8282
     progress_dialog: ProgressDialog,
83
+    /// Context menu for right-click actions.
84
+    context_menu: ContextMenu,
8385
     /// Paths pending delete confirmation.
8486
     pending_delete_paths: Vec<PathBuf>,
8587
     /// Undo/redo stack for file operations.
@@ -196,6 +198,9 @@ impl App {
196198
         // Create progress dialog (full window bounds)
197199
         let progress_dialog = ProgressDialog::new(Rect::new(0, 0, width, height));
198200
 
201
+        // Create context menu (full window bounds for positioning)
202
+        let context_menu = ContextMenu::new(Rect::new(0, 0, width, height));
203
+
199204
         // Content area bounds (for panes)
200205
         let content_bounds = Rect::new(
201206
             sidebar_w as i32,
@@ -244,6 +249,7 @@ impl App {
244249
             confirm_dialog,
245250
             conflict_dialog,
246251
             progress_dialog,
252
+            context_menu,
247253
             pending_delete_paths: Vec::new(),
248254
             undo_stack: UndoStack::new(),
249255
             pending_paste: None,
@@ -358,6 +364,20 @@ impl App {
358364
             return;
359365
         }
360366
 
367
+        // Check context menu
368
+        if self.context_menu.is_visible() {
369
+            if let Some(action) = self.context_menu.on_click(pos) {
370
+                self.handle_context_menu_action(action);
371
+            }
372
+            return;
373
+        }
374
+
375
+        // Right-click shows context menu
376
+        if button == Some(MouseButton::Right) {
377
+            self.show_context_menu(pos);
378
+            return;
379
+        }
380
+
361381
         // Handle middle-click on tab bar to close tab
362382
         if button == Some(MouseButton::Middle) {
363383
             if let Some(index) = self.tab_bar.tab_at_point(pos) {
@@ -592,6 +612,12 @@ impl App {
592612
             return;
593613
         }
594614
 
615
+        // Handle context menu hover
616
+        if self.context_menu.is_visible() {
617
+            self.context_menu.on_mouse_move(pos);
618
+            return;
619
+        }
620
+
595621
         // Handle sidebar resize in progress
596622
         if self.sidebar_resizing {
597623
             let new_width = (pos.x - self.sidebar.bounds().x).max(0) as u32;
@@ -695,6 +721,14 @@ impl App {
695721
             return;
696722
         }
697723
 
724
+        // Handle context menu when visible
725
+        if self.context_menu.is_visible() {
726
+            if let Some(action) = self.context_menu.handle_key(key) {
727
+                self.handle_context_menu_action(action);
728
+            }
729
+            return;
730
+        }
731
+
698732
         // F1 toggles help
699733
         if *key == Key::F1 {
700734
             self.help_modal.show();
@@ -764,13 +798,21 @@ impl App {
764798
             }
765799
         }
766800
 
767
-        // Ctrl+Shift keybinds (splits, new folder)
801
+        // Ctrl+Shift keybinds (splits, new folder, new file, duplicate)
768802
         if modifiers.ctrl && modifiers.shift {
769803
             match key {
770804
                 Key::Char('n') | Key::Char('N') => {
771805
                     self.create_new_folder();
772806
                     return;
773807
                 }
808
+                Key::Char('f') | Key::Char('F') => {
809
+                    self.create_new_file();
810
+                    return;
811
+                }
812
+                Key::Char('d') | Key::Char('D') => {
813
+                    self.duplicate_selected();
814
+                    return;
815
+                }
774816
                 Key::Char('h') | Key::Char('H') => {
775817
                     self.split_horizontal();
776818
                     return;
@@ -1699,6 +1741,122 @@ impl App {
16991741
         let _ = self.window.connection().flush();
17001742
     }
17011743
 
1744
+    /// Show the context menu at the given position.
1745
+    fn show_context_menu(&mut self, pos: Point) {
1746
+        // Determine context type based on what's under the cursor
1747
+        let (context_type, selected_count) = if let Some(pane) = self.focused_pane() {
1748
+            if let Some(tab) = pane.active_tab() {
1749
+                let selected = tab.selected_paths();
1750
+
1751
+                if let Some(entry) = tab.entry_at_point(pos) {
1752
+                    // Clicked on an item
1753
+                    if selected.len() > 1 && selected.contains(&entry.path) {
1754
+                        (ContextType::MultiSelection, selected.len())
1755
+                    } else if entry.is_dir() {
1756
+                        (ContextType::Folder, 1)
1757
+                    } else {
1758
+                        (ContextType::File, 1)
1759
+                    }
1760
+                } else {
1761
+                    // Clicked on empty space
1762
+                    (ContextType::EmptySpace, 0)
1763
+                }
1764
+            } else {
1765
+                (ContextType::EmptySpace, 0)
1766
+            }
1767
+        } else {
1768
+            (ContextType::EmptySpace, 0)
1769
+        };
1770
+
1771
+        let has_clipboard = self.clipboard.has_files();
1772
+        self.context_menu.show(pos, context_type, selected_count, has_clipboard);
1773
+    }
1774
+
1775
+    /// Handle a context menu action.
1776
+    fn handle_context_menu_action(&mut self, action: ContextMenuAction) {
1777
+        match action {
1778
+            ContextMenuAction::Open => self.enter_selected(),
1779
+            ContextMenuAction::OpenWith(app) => self.open_with(&app),
1780
+            ContextMenuAction::OpenInNewTab => self.open_in_new_tab(),
1781
+            ContextMenuAction::Copy | ContextMenuAction::CopyAll => self.copy_selected(),
1782
+            ContextMenuAction::Cut | ContextMenuAction::CutAll => self.cut_selected(),
1783
+            ContextMenuAction::Duplicate => self.duplicate_selected(),
1784
+            ContextMenuAction::Rename => self.start_rename(),
1785
+            ContextMenuAction::Trash | ContextMenuAction::TrashAll => self.trash_selected(),
1786
+            ContextMenuAction::Delete | ContextMenuAction::DeleteAll => self.delete_selected_permanently(),
1787
+            ContextMenuAction::Properties => self.show_properties(),
1788
+            ContextMenuAction::NewFile => self.create_new_file(),
1789
+            ContextMenuAction::NewFolder => self.create_new_folder(),
1790
+            ContextMenuAction::Paste => self.paste(),
1791
+            ContextMenuAction::Refresh => self.refresh(),
1792
+            ContextMenuAction::ViewList => self.set_view_mode(ViewMode::List),
1793
+            ContextMenuAction::ViewGrid => self.set_view_mode(ViewMode::Grid),
1794
+            ContextMenuAction::ViewColumns => self.set_view_mode(ViewMode::Columns),
1795
+            ContextMenuAction::SortByName => self.set_sort_order(garfield::core::SortOrder::Name),
1796
+            ContextMenuAction::SortBySize => self.set_sort_order(garfield::core::SortOrder::Size),
1797
+            ContextMenuAction::SortByDate => self.set_sort_order(garfield::core::SortOrder::Modified),
1798
+            ContextMenuAction::SortByType => self.set_sort_order(garfield::core::SortOrder::Type),
1799
+        }
1800
+    }
1801
+
1802
+    /// Open selected item with a specific application.
1803
+    fn open_with(&mut self, app: &str) {
1804
+        if app.is_empty() {
1805
+            self.status_bar.set_status_message("Application picker not implemented");
1806
+            return;
1807
+        }
1808
+
1809
+        let paths = self.get_selected_paths();
1810
+        if let Some(path) = paths.first() {
1811
+            match std::process::Command::new(app).arg(path).spawn() {
1812
+                Ok(_) => self.status_bar.set_status_message(format!("Opened with {}", app)),
1813
+                Err(e) => self.status_bar.set_status_message(format!("Failed: {}", e)),
1814
+            }
1815
+        }
1816
+    }
1817
+
1818
+    /// Open folder in new tab.
1819
+    fn open_in_new_tab(&mut self) {
1820
+        let entry_path = self.focused_pane()
1821
+            .and_then(|p| p.active_tab())
1822
+            .and_then(|t| t.selected_entry())
1823
+            .filter(|e| e.is_dir())
1824
+            .map(|e| e.path.clone());
1825
+
1826
+        if let Some(path) = entry_path {
1827
+            if let Some(pane) = self.focused_pane_mut() {
1828
+                pane.add_tab(path);
1829
+            }
1830
+            self.sync_tab_bar();
1831
+            self.sync_breadcrumb();
1832
+            self.update_status_bar();
1833
+        }
1834
+    }
1835
+
1836
+    /// Show properties dialog (placeholder).
1837
+    fn show_properties(&mut self) {
1838
+        self.status_bar.set_status_message("Properties dialog not implemented");
1839
+    }
1840
+
1841
+    /// Set sort order from context menu.
1842
+    fn set_sort_order(&mut self, order: garfield::core::SortOrder) {
1843
+        if let Some(pane) = self.focused_pane_mut() {
1844
+            if let Some(tab) = pane.active_tab_mut() {
1845
+                let current_dir = tab.sort_direction();
1846
+                // Toggle direction if same order
1847
+                let new_dir = if tab.sort_order() == order {
1848
+                    match current_dir {
1849
+                        garfield::core::SortDirection::Ascending => garfield::core::SortDirection::Descending,
1850
+                        garfield::core::SortDirection::Descending => garfield::core::SortDirection::Ascending,
1851
+                    }
1852
+                } else {
1853
+                    garfield::core::SortDirection::Ascending
1854
+                };
1855
+                tab.set_sort(order, new_dir);
1856
+            }
1857
+        }
1858
+    }
1859
+
17021860
     /// Create a new folder in the current directory.
17031861
     fn create_new_folder(&mut self) {
17041862
         let current_dir = self.focused_pane()
@@ -1732,6 +1890,106 @@ impl App {
17321890
         }
17331891
     }
17341892
 
1893
+    /// Create a new empty file in the current directory.
1894
+    fn create_new_file(&mut self) {
1895
+        use garfield::core::make_unique_name;
1896
+
1897
+        let current_dir = self.focused_pane()
1898
+            .and_then(|p| p.active_tab())
1899
+            .map(|t| t.current_path().clone());
1900
+
1901
+        let current_dir = match current_dir {
1902
+            Some(d) => d,
1903
+            None => return,
1904
+        };
1905
+
1906
+        // Generate unique name
1907
+        let unique_name = make_unique_name(&current_dir, "New File");
1908
+        let path = current_dir.join(&unique_name);
1909
+
1910
+        match std::fs::File::create(&path) {
1911
+            Ok(_) => {
1912
+                self.status_bar.set_status_message(format!("Created '{}'", unique_name));
1913
+                self.refresh();
1914
+
1915
+                // Select the new file and start rename
1916
+                if let Some(pane) = self.focused_pane_mut() {
1917
+                    if let Some(tab) = pane.active_tab_mut() {
1918
+                        if tab.select_by_name(&unique_name) {
1919
+                            tab.start_rename();
1920
+                        }
1921
+                    }
1922
+                }
1923
+            }
1924
+            Err(e) => {
1925
+                self.status_bar.set_status_message(format!("Failed to create file: {}", e));
1926
+            }
1927
+        }
1928
+    }
1929
+
1930
+    /// Duplicate the selected files/folders in the current directory.
1931
+    fn duplicate_selected(&mut self) {
1932
+        use garfield::core::make_unique_name;
1933
+
1934
+        let selected = self.get_selected_paths();
1935
+        if selected.is_empty() {
1936
+            self.status_bar.set_status_message("No items selected");
1937
+            return;
1938
+        }
1939
+
1940
+        let dest_dir = self.focused_pane()
1941
+            .and_then(|p| p.active_tab())
1942
+            .map(|t| t.current_path().clone());
1943
+
1944
+        let dest_dir = match dest_dir {
1945
+            Some(d) => d,
1946
+            None => return,
1947
+        };
1948
+
1949
+        let mut success_count = 0;
1950
+        let mut last_created_name = String::new();
1951
+
1952
+        for path in &selected {
1953
+            if let Some(name) = path.file_name() {
1954
+                let name_str = name.to_string_lossy();
1955
+                let unique_name = make_unique_name(&dest_dir, &name_str);
1956
+                let dest = dest_dir.join(&unique_name);
1957
+
1958
+                let result = if path.is_dir() {
1959
+                    garfield::core::copy_to_path(path, &dest)
1960
+                } else {
1961
+                    std::fs::copy(path, &dest).map(|_| dest.clone())
1962
+                };
1963
+
1964
+                if result.is_ok() {
1965
+                    success_count += 1;
1966
+                    last_created_name = unique_name;
1967
+                }
1968
+            }
1969
+        }
1970
+
1971
+        if success_count > 0 {
1972
+            let msg = if success_count == 1 {
1973
+                format!("Duplicated as '{}'", last_created_name)
1974
+            } else {
1975
+                format!("Duplicated {} items", success_count)
1976
+            };
1977
+            self.status_bar.set_status_message(msg);
1978
+            self.refresh();
1979
+
1980
+            // Select the last duplicated item
1981
+            if !last_created_name.is_empty() {
1982
+                if let Some(pane) = self.focused_pane_mut() {
1983
+                    if let Some(tab) = pane.active_tab_mut() {
1984
+                        tab.select_by_name(&last_created_name);
1985
+                    }
1986
+                }
1987
+            }
1988
+        } else {
1989
+            self.status_bar.set_status_message("Failed to duplicate items");
1990
+        }
1991
+    }
1992
+
17351993
     /// Undo the last file operation.
17361994
     fn undo(&mut self) {
17371995
         let op = match self.undo_stack.pop_undo() {
@@ -2087,6 +2345,7 @@ impl App {
20872345
         self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height));
20882346
         self.conflict_dialog.set_bounds(Rect::new(0, 0, width, height));
20892347
         self.progress_dialog.set_bounds(Rect::new(0, 0, width, height));
2348
+        self.context_menu.set_bounds(Rect::new(0, 0, width, height));
20902349
     }
20912350
 
20922351
     /// Render the application.
@@ -2156,6 +2415,9 @@ impl App {
21562415
         // Draw conflict dialog overlay (on top of everything)
21572416
         self.conflict_dialog.render(&self.renderer)?;
21582417
 
2418
+        // Draw context menu overlay
2419
+        self.context_menu.render(&self.renderer)?;
2420
+
21592421
         // Draw progress dialog overlay (on top of everything)
21602422
         self.progress_dialog.render(&self.renderer)?;
21612423
 
garfield/src/ui/context_menu.rsadded
@@ -0,0 +1,912 @@
1
+//! Context menu component for right-click actions.
2
+
3
+use anyhow::Result;
4
+use gartk_core::{Key, Point, Rect};
5
+use gartk_render::{Renderer, TextStyle};
6
+use std::time::Instant;
7
+
8
+/// Menu item height.
9
+const ITEM_HEIGHT: u32 = 28;
10
+
11
+/// Separator height.
12
+const SEPARATOR_HEIGHT: u32 = 9;
13
+
14
+/// Hover delay before opening submenu (milliseconds).
15
+const SUBMENU_HOVER_DELAY_MS: u64 = 300;
16
+
17
+/// Minimum menu width.
18
+const MIN_MENU_WIDTH: u32 = 200;
19
+
20
+/// Padding inside menu.
21
+const MENU_PADDING: u32 = 4;
22
+
23
+/// Context type determining which menu items to show.
24
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25
+pub enum ContextType {
26
+    /// Right-click on a file.
27
+    File,
28
+    /// Right-click on a folder.
29
+    Folder,
30
+    /// Right-click on empty space in file view.
31
+    EmptySpace,
32
+    /// Multi-selection context.
33
+    MultiSelection,
34
+}
35
+
36
+/// A context menu action.
37
+#[derive(Debug, Clone, PartialEq, Eq)]
38
+pub enum ContextMenuAction {
39
+    // File/Folder actions
40
+    Open,
41
+    OpenWith(String),
42
+    OpenInNewTab,
43
+    Copy,
44
+    Cut,
45
+    Duplicate,
46
+    Rename,
47
+    Trash,
48
+    Delete,
49
+    Properties,
50
+
51
+    // Empty space actions
52
+    NewFile,
53
+    NewFolder,
54
+    Paste,
55
+    Refresh,
56
+
57
+    // View submenu actions
58
+    ViewList,
59
+    ViewGrid,
60
+    ViewColumns,
61
+
62
+    // Sort submenu actions
63
+    SortByName,
64
+    SortBySize,
65
+    SortByDate,
66
+    SortByType,
67
+
68
+    // Multi-selection actions (same as single but for clarity)
69
+    CopyAll,
70
+    CutAll,
71
+    TrashAll,
72
+    DeleteAll,
73
+}
74
+
75
+/// A single menu item.
76
+#[derive(Debug, Clone)]
77
+pub enum MenuItem {
78
+    /// Regular action item.
79
+    Action {
80
+        label: String,
81
+        action: ContextMenuAction,
82
+        shortcut: Option<String>,
83
+        enabled: bool,
84
+    },
85
+    /// Separator line.
86
+    Separator,
87
+    /// Submenu with nested items.
88
+    Submenu {
89
+        label: String,
90
+        items: Vec<MenuItem>,
91
+    },
92
+}
93
+
94
+impl MenuItem {
95
+    /// Create a new action item.
96
+    pub fn action(label: &str, action: ContextMenuAction) -> Self {
97
+        MenuItem::Action {
98
+            label: label.to_string(),
99
+            action,
100
+            shortcut: None,
101
+            enabled: true,
102
+        }
103
+    }
104
+
105
+    /// Add a keyboard shortcut hint.
106
+    pub fn with_shortcut(self, shortcut: &str) -> Self {
107
+        match self {
108
+            MenuItem::Action { label, action, enabled, .. } => MenuItem::Action {
109
+                label,
110
+                action,
111
+                shortcut: Some(shortcut.to_string()),
112
+                enabled,
113
+            },
114
+            other => other,
115
+        }
116
+    }
117
+
118
+    /// Set enabled state.
119
+    pub fn with_enabled(self, enabled: bool) -> Self {
120
+        match self {
121
+            MenuItem::Action { label, action, shortcut, .. } => MenuItem::Action {
122
+                label,
123
+                action,
124
+                shortcut,
125
+                enabled,
126
+            },
127
+            other => other,
128
+        }
129
+    }
130
+
131
+    /// Create a submenu.
132
+    pub fn submenu(label: &str, items: Vec<MenuItem>) -> Self {
133
+        MenuItem::Submenu {
134
+            label: label.to_string(),
135
+            items,
136
+        }
137
+    }
138
+
139
+    /// Create a separator.
140
+    pub fn separator() -> Self {
141
+        MenuItem::Separator
142
+    }
143
+
144
+    /// Check if this is a separator.
145
+    fn is_separator(&self) -> bool {
146
+        matches!(self, MenuItem::Separator)
147
+    }
148
+
149
+    /// Get the height of this item.
150
+    fn height(&self) -> u32 {
151
+        match self {
152
+            MenuItem::Separator => SEPARATOR_HEIGHT,
153
+            _ => ITEM_HEIGHT,
154
+        }
155
+    }
156
+}
157
+
158
+/// Rendered menu item with computed bounds.
159
+struct RenderedItem {
160
+    item: MenuItem,
161
+    bounds: Rect,
162
+}
163
+
164
+/// Context menu state.
165
+pub struct ContextMenu {
166
+    /// Window bounds (for clipping/positioning).
167
+    window_bounds: Rect,
168
+    /// Menu bounds (position and size).
169
+    menu_bounds: Rect,
170
+    /// Whether the menu is visible.
171
+    visible: bool,
172
+    /// Context type.
173
+    context_type: ContextType,
174
+    /// Menu items.
175
+    items: Vec<MenuItem>,
176
+    /// Rendered items with bounds.
177
+    rendered_items: Vec<RenderedItem>,
178
+    /// Currently focused index (for keyboard nav).
179
+    focused_index: Option<usize>,
180
+    /// Hovered item index.
181
+    hovered_index: Option<usize>,
182
+    /// Open submenu index (if any).
183
+    open_submenu_index: Option<usize>,
184
+    /// Submenu focused index.
185
+    submenu_focused_index: Option<usize>,
186
+    /// Submenu hovered index.
187
+    submenu_hovered_index: Option<usize>,
188
+    /// Submenu items bounds.
189
+    submenu_bounds: Option<Rect>,
190
+    /// Submenu rendered items.
191
+    submenu_rendered_items: Vec<RenderedItem>,
192
+    /// Time when hover started on a submenu item (for delay).
193
+    submenu_hover_start: Option<Instant>,
194
+    /// Number of selected items (for multi-select labels).
195
+    selected_count: usize,
196
+    /// Whether clipboard has content (for paste enable).
197
+    has_clipboard: bool,
198
+}
199
+
200
+impl ContextMenu {
201
+    /// Create a new context menu.
202
+    pub fn new(window_bounds: Rect) -> Self {
203
+        Self {
204
+            window_bounds,
205
+            menu_bounds: Rect::new(0, 0, MIN_MENU_WIDTH, 0),
206
+            visible: false,
207
+            context_type: ContextType::EmptySpace,
208
+            items: Vec::new(),
209
+            rendered_items: Vec::new(),
210
+            focused_index: None,
211
+            hovered_index: None,
212
+            open_submenu_index: None,
213
+            submenu_focused_index: None,
214
+            submenu_hovered_index: None,
215
+            submenu_bounds: None,
216
+            submenu_rendered_items: Vec::new(),
217
+            submenu_hover_start: None,
218
+            selected_count: 0,
219
+            has_clipboard: false,
220
+        }
221
+    }
222
+
223
+    /// Set window bounds.
224
+    pub fn set_bounds(&mut self, bounds: Rect) {
225
+        self.window_bounds = bounds;
226
+    }
227
+
228
+    /// Check if visible.
229
+    pub fn is_visible(&self) -> bool {
230
+        self.visible
231
+    }
232
+
233
+    /// Hide the menu.
234
+    pub fn hide(&mut self) {
235
+        self.visible = false;
236
+        self.items.clear();
237
+        self.rendered_items.clear();
238
+        self.focused_index = None;
239
+        self.hovered_index = None;
240
+        self.open_submenu_index = None;
241
+        self.submenu_focused_index = None;
242
+        self.submenu_hovered_index = None;
243
+        self.submenu_bounds = None;
244
+        self.submenu_rendered_items.clear();
245
+        self.submenu_hover_start = None;
246
+    }
247
+
248
+    /// Show the context menu at position.
249
+    pub fn show(
250
+        &mut self,
251
+        pos: Point,
252
+        context_type: ContextType,
253
+        selected_count: usize,
254
+        has_clipboard: bool,
255
+    ) {
256
+        self.context_type = context_type;
257
+        self.selected_count = selected_count;
258
+        self.has_clipboard = has_clipboard;
259
+
260
+        // Build menu items based on context
261
+        self.items = self.build_menu_items();
262
+
263
+        // Calculate menu size
264
+        let (width, height) = self.calculate_menu_size(&self.items);
265
+
266
+        // Position menu (flip if near edges)
267
+        let (x, y) = self.calculate_position(pos, width, height);
268
+
269
+        self.menu_bounds = Rect::new(x, y, width, height);
270
+        self.layout_items();
271
+
272
+        self.visible = true;
273
+        self.focused_index = Some(0);
274
+        self.hovered_index = None;
275
+        self.open_submenu_index = None;
276
+    }
277
+
278
+    /// Build menu items based on context type.
279
+    fn build_menu_items(&self) -> Vec<MenuItem> {
280
+        match self.context_type {
281
+            ContextType::File => self.build_file_menu(),
282
+            ContextType::Folder => self.build_folder_menu(),
283
+            ContextType::EmptySpace => self.build_empty_space_menu(),
284
+            ContextType::MultiSelection => self.build_multi_selection_menu(),
285
+        }
286
+    }
287
+
288
+    fn build_file_menu(&self) -> Vec<MenuItem> {
289
+        vec![
290
+            MenuItem::action("Open", ContextMenuAction::Open),
291
+            MenuItem::submenu("Open With", self.build_open_with_submenu()),
292
+            MenuItem::separator(),
293
+            MenuItem::action("Copy", ContextMenuAction::Copy).with_shortcut("Ctrl+C"),
294
+            MenuItem::action("Cut", ContextMenuAction::Cut).with_shortcut("Ctrl+X"),
295
+            MenuItem::action("Duplicate", ContextMenuAction::Duplicate).with_shortcut("Ctrl+Shift+D"),
296
+            MenuItem::separator(),
297
+            MenuItem::action("Rename", ContextMenuAction::Rename).with_shortcut("F2"),
298
+            MenuItem::action("Move to Trash", ContextMenuAction::Trash).with_shortcut("Del"),
299
+            MenuItem::action("Delete Permanently", ContextMenuAction::Delete).with_shortcut("Shift+Del"),
300
+            MenuItem::separator(),
301
+            MenuItem::action("Properties", ContextMenuAction::Properties),
302
+        ]
303
+    }
304
+
305
+    fn build_folder_menu(&self) -> Vec<MenuItem> {
306
+        vec![
307
+            MenuItem::action("Open", ContextMenuAction::Open),
308
+            MenuItem::action("Open in New Tab", ContextMenuAction::OpenInNewTab),
309
+            MenuItem::submenu("Open With", self.build_open_with_submenu()),
310
+            MenuItem::separator(),
311
+            MenuItem::action("Copy", ContextMenuAction::Copy).with_shortcut("Ctrl+C"),
312
+            MenuItem::action("Cut", ContextMenuAction::Cut).with_shortcut("Ctrl+X"),
313
+            MenuItem::action("Duplicate", ContextMenuAction::Duplicate).with_shortcut("Ctrl+Shift+D"),
314
+            MenuItem::separator(),
315
+            MenuItem::action("Rename", ContextMenuAction::Rename).with_shortcut("F2"),
316
+            MenuItem::action("Move to Trash", ContextMenuAction::Trash).with_shortcut("Del"),
317
+            MenuItem::action("Delete Permanently", ContextMenuAction::Delete).with_shortcut("Shift+Del"),
318
+            MenuItem::separator(),
319
+            MenuItem::action("Properties", ContextMenuAction::Properties),
320
+        ]
321
+    }
322
+
323
+    fn build_empty_space_menu(&self) -> Vec<MenuItem> {
324
+        vec![
325
+            MenuItem::action("New File", ContextMenuAction::NewFile).with_shortcut("Ctrl+Shift+F"),
326
+            MenuItem::action("New Folder", ContextMenuAction::NewFolder).with_shortcut("Ctrl+Shift+N"),
327
+            MenuItem::separator(),
328
+            MenuItem::action("Paste", ContextMenuAction::Paste)
329
+                .with_shortcut("Ctrl+V")
330
+                .with_enabled(self.has_clipboard),
331
+            MenuItem::separator(),
332
+            MenuItem::action("Refresh", ContextMenuAction::Refresh).with_shortcut("F5"),
333
+            MenuItem::separator(),
334
+            MenuItem::submenu("View", self.build_view_submenu()),
335
+            MenuItem::submenu("Sort By", self.build_sort_submenu()),
336
+        ]
337
+    }
338
+
339
+    fn build_multi_selection_menu(&self) -> Vec<MenuItem> {
340
+        let count = self.selected_count;
341
+        vec![
342
+            MenuItem::action(&format!("Copy {} items", count), ContextMenuAction::CopyAll)
343
+                .with_shortcut("Ctrl+C"),
344
+            MenuItem::action(&format!("Cut {} items", count), ContextMenuAction::CutAll)
345
+                .with_shortcut("Ctrl+X"),
346
+            MenuItem::separator(),
347
+            MenuItem::action(&format!("Move {} items to Trash", count), ContextMenuAction::TrashAll)
348
+                .with_shortcut("Del"),
349
+            MenuItem::action(&format!("Delete {} items", count), ContextMenuAction::DeleteAll)
350
+                .with_shortcut("Shift+Del"),
351
+        ]
352
+    }
353
+
354
+    fn build_open_with_submenu(&self) -> Vec<MenuItem> {
355
+        vec![
356
+            MenuItem::action("Default Application", ContextMenuAction::OpenWith("xdg-open".to_string())),
357
+            MenuItem::action("Text Editor", ContextMenuAction::OpenWith("xdg-open".to_string())),
358
+            MenuItem::separator(),
359
+            MenuItem::action("Other Application...", ContextMenuAction::OpenWith(String::new())),
360
+        ]
361
+    }
362
+
363
+    fn build_view_submenu(&self) -> Vec<MenuItem> {
364
+        vec![
365
+            MenuItem::action("List", ContextMenuAction::ViewList).with_shortcut("Ctrl+1"),
366
+            MenuItem::action("Grid", ContextMenuAction::ViewGrid).with_shortcut("Ctrl+2"),
367
+            MenuItem::action("Columns", ContextMenuAction::ViewColumns).with_shortcut("Ctrl+3"),
368
+        ]
369
+    }
370
+
371
+    fn build_sort_submenu(&self) -> Vec<MenuItem> {
372
+        vec![
373
+            MenuItem::action("Name", ContextMenuAction::SortByName),
374
+            MenuItem::action("Size", ContextMenuAction::SortBySize),
375
+            MenuItem::action("Date Modified", ContextMenuAction::SortByDate),
376
+            MenuItem::action("Type", ContextMenuAction::SortByType),
377
+        ]
378
+    }
379
+
380
+    /// Calculate menu dimensions.
381
+    fn calculate_menu_size(&self, items: &[MenuItem]) -> (u32, u32) {
382
+        let height: u32 = items.iter().map(|i| i.height()).sum::<u32>() + (MENU_PADDING * 2);
383
+        let width = MIN_MENU_WIDTH + 60; // Extra space for shortcuts
384
+        (width, height)
385
+    }
386
+
387
+    /// Calculate menu position, flipping if near window edges.
388
+    fn calculate_position(&self, pos: Point, width: u32, height: u32) -> (i32, i32) {
389
+        let mut x = pos.x;
390
+        let mut y = pos.y;
391
+
392
+        // Flip horizontally if menu would extend past right edge
393
+        if x + width as i32 > self.window_bounds.x + self.window_bounds.width as i32 {
394
+            x = pos.x - width as i32;
395
+        }
396
+
397
+        // Flip vertically if menu would extend past bottom edge
398
+        if y + height as i32 > self.window_bounds.y + self.window_bounds.height as i32 {
399
+            y = pos.y - height as i32;
400
+        }
401
+
402
+        // Ensure menu stays within bounds
403
+        x = x.max(self.window_bounds.x);
404
+        y = y.max(self.window_bounds.y);
405
+
406
+        (x, y)
407
+    }
408
+
409
+    /// Layout items and calculate their bounds.
410
+    fn layout_items(&mut self) {
411
+        self.rendered_items.clear();
412
+        let mut y = self.menu_bounds.y + MENU_PADDING as i32;
413
+
414
+        for item in &self.items {
415
+            let height = item.height();
416
+
417
+            let bounds = Rect::new(
418
+                self.menu_bounds.x + MENU_PADDING as i32,
419
+                y,
420
+                self.menu_bounds.width - MENU_PADDING * 2,
421
+                height,
422
+            );
423
+
424
+            self.rendered_items.push(RenderedItem {
425
+                item: item.clone(),
426
+                bounds,
427
+            });
428
+
429
+            y += height as i32;
430
+        }
431
+    }
432
+
433
+    /// Layout submenu items.
434
+    fn layout_submenu(&mut self, parent_bounds: &Rect, items: &[MenuItem]) {
435
+        let (width, height) = self.calculate_menu_size(items);
436
+
437
+        // Try to open to the right
438
+        let mut x = parent_bounds.x + parent_bounds.width as i32 - 4;
439
+        let y = parent_bounds.y;
440
+
441
+        // If would overflow right edge, open to left
442
+        if x + width as i32 > self.window_bounds.x + self.window_bounds.width as i32 {
443
+            x = self.menu_bounds.x - width as i32 + 4;
444
+        }
445
+
446
+        self.submenu_bounds = Some(Rect::new(x, y, width, height));
447
+        self.submenu_rendered_items.clear();
448
+
449
+        let mut item_y = y + MENU_PADDING as i32;
450
+        for item in items {
451
+            let item_height = item.height();
452
+            let bounds = Rect::new(
453
+                x + MENU_PADDING as i32,
454
+                item_y,
455
+                width - MENU_PADDING * 2,
456
+                item_height,
457
+            );
458
+            self.submenu_rendered_items.push(RenderedItem {
459
+                item: item.clone(),
460
+                bounds,
461
+            });
462
+            item_y += item_height as i32;
463
+        }
464
+    }
465
+
466
+    /// Handle mouse move.
467
+    pub fn on_mouse_move(&mut self, pos: Point) {
468
+        if !self.visible {
469
+            return;
470
+        }
471
+
472
+        // Check submenu first if open
473
+        if let Some(ref submenu_bounds) = self.submenu_bounds {
474
+            if submenu_bounds.contains_point(pos) {
475
+                // Mouse is in submenu
476
+                self.submenu_hovered_index = self.submenu_rendered_items
477
+                    .iter()
478
+                    .position(|r| r.bounds.contains_point(pos) && !r.item.is_separator());
479
+                if let Some(idx) = self.submenu_hovered_index {
480
+                    self.submenu_focused_index = Some(idx);
481
+                }
482
+                return;
483
+            }
484
+        }
485
+
486
+        // Check main menu
487
+        self.submenu_hovered_index = None;
488
+
489
+        for (i, rendered) in self.rendered_items.iter().enumerate() {
490
+            if rendered.bounds.contains_point(pos) && !rendered.item.is_separator() {
491
+                self.hovered_index = Some(i);
492
+                self.focused_index = Some(i);
493
+
494
+                // Handle submenu hover
495
+                if matches!(&rendered.item, MenuItem::Submenu { .. }) {
496
+                    if self.open_submenu_index != Some(i) {
497
+                        if self.submenu_hover_start.is_none() {
498
+                            self.submenu_hover_start = Some(Instant::now());
499
+                        } else if let Some(start) = self.submenu_hover_start {
500
+                            if start.elapsed().as_millis() > SUBMENU_HOVER_DELAY_MS as u128 {
501
+                                self.open_submenu(i);
502
+                            }
503
+                        }
504
+                    }
505
+                } else {
506
+                    // Not a submenu, close any open submenu
507
+                    self.submenu_hover_start = None;
508
+                    if self.open_submenu_index.is_some() {
509
+                        self.close_submenu();
510
+                    }
511
+                }
512
+                return;
513
+            }
514
+        }
515
+
516
+        // Mouse not over any item
517
+        self.hovered_index = None;
518
+
519
+        // Check if mouse left menu area entirely
520
+        if !self.menu_bounds.contains_point(pos) {
521
+            if let Some(ref submenu_bounds) = self.submenu_bounds {
522
+                if !submenu_bounds.contains_point(pos) {
523
+                    self.submenu_hover_start = None;
524
+                }
525
+            } else {
526
+                self.submenu_hover_start = None;
527
+            }
528
+        }
529
+    }
530
+
531
+    /// Open a submenu by index.
532
+    fn open_submenu(&mut self, index: usize) {
533
+        // Get the bounds and items from the rendered item
534
+        let (bounds, items) = if let Some(rendered) = self.rendered_items.get(index) {
535
+            if let MenuItem::Submenu { items, .. } = &rendered.item {
536
+                (rendered.bounds, items.clone())
537
+            } else {
538
+                return;
539
+            }
540
+        } else {
541
+            return;
542
+        };
543
+
544
+        self.layout_submenu(&bounds, &items);
545
+        self.open_submenu_index = Some(index);
546
+        self.submenu_focused_index = Some(0);
547
+        self.submenu_hovered_index = None;
548
+    }
549
+
550
+    /// Close the open submenu.
551
+    fn close_submenu(&mut self) {
552
+        self.open_submenu_index = None;
553
+        self.submenu_focused_index = None;
554
+        self.submenu_hovered_index = None;
555
+        self.submenu_bounds = None;
556
+        self.submenu_rendered_items.clear();
557
+    }
558
+
559
+    /// Handle click. Returns action if item was clicked.
560
+    pub fn on_click(&mut self, pos: Point) -> Option<ContextMenuAction> {
561
+        if !self.visible {
562
+            return None;
563
+        }
564
+
565
+        // Check submenu click first
566
+        if let Some(ref submenu_bounds) = self.submenu_bounds {
567
+            if submenu_bounds.contains_point(pos) {
568
+                for rendered in &self.submenu_rendered_items {
569
+                    if rendered.bounds.contains_point(pos) {
570
+                        if let MenuItem::Action { action, enabled, .. } = &rendered.item {
571
+                            if *enabled {
572
+                                let action = action.clone();
573
+                                self.hide();
574
+                                return Some(action);
575
+                            }
576
+                        }
577
+                    }
578
+                }
579
+                return None;
580
+            }
581
+        }
582
+
583
+        // Check main menu click
584
+        for rendered in &self.rendered_items {
585
+            if rendered.bounds.contains_point(pos) {
586
+                match &rendered.item {
587
+                    MenuItem::Action { action, enabled, .. } => {
588
+                        if *enabled {
589
+                            let action = action.clone();
590
+                            self.hide();
591
+                            return Some(action);
592
+                        }
593
+                    }
594
+                    MenuItem::Submenu { .. } => {
595
+                        // Clicking on submenu parent opens it
596
+                        if let Some(idx) = self.rendered_items.iter().position(|r| std::ptr::eq(r, rendered)) {
597
+                            self.open_submenu(idx);
598
+                        }
599
+                        return None;
600
+                    }
601
+                    MenuItem::Separator => {}
602
+                }
603
+            }
604
+        }
605
+
606
+        // Click outside menu closes it
607
+        if !self.menu_bounds.contains_point(pos) {
608
+            if let Some(ref submenu_bounds) = self.submenu_bounds {
609
+                if !submenu_bounds.contains_point(pos) {
610
+                    self.hide();
611
+                }
612
+            } else {
613
+                self.hide();
614
+            }
615
+        }
616
+
617
+        None
618
+    }
619
+
620
+    /// Handle keyboard input. Returns action if selected.
621
+    pub fn handle_key(&mut self, key: &Key) -> Option<ContextMenuAction> {
622
+        if !self.visible {
623
+            return None;
624
+        }
625
+
626
+        match key {
627
+            Key::Escape => {
628
+                // If submenu is open, close it; otherwise close menu
629
+                if self.open_submenu_index.is_some() {
630
+                    self.close_submenu();
631
+                } else {
632
+                    self.hide();
633
+                }
634
+                None
635
+            }
636
+            Key::Up => {
637
+                if self.open_submenu_index.is_some() {
638
+                    self.submenu_move_focus(-1);
639
+                } else {
640
+                    self.move_focus(-1);
641
+                }
642
+                None
643
+            }
644
+            Key::Down => {
645
+                if self.open_submenu_index.is_some() {
646
+                    self.submenu_move_focus(1);
647
+                } else {
648
+                    self.move_focus(1);
649
+                }
650
+                None
651
+            }
652
+            Key::Right => {
653
+                // Open submenu if focused item is a submenu
654
+                if let Some(idx) = self.focused_index {
655
+                    if let Some(rendered) = self.rendered_items.get(idx) {
656
+                        if matches!(&rendered.item, MenuItem::Submenu { .. }) {
657
+                            self.open_submenu(idx);
658
+                        }
659
+                    }
660
+                }
661
+                None
662
+            }
663
+            Key::Left => {
664
+                // Close submenu
665
+                if self.open_submenu_index.is_some() {
666
+                    self.close_submenu();
667
+                }
668
+                None
669
+            }
670
+            Key::Return => self.activate_focused(),
671
+            _ => None,
672
+        }
673
+    }
674
+
675
+    /// Move focus by delta (skipping separators).
676
+    fn move_focus(&mut self, delta: i32) {
677
+        let actionable_indices: Vec<usize> = self.rendered_items
678
+            .iter()
679
+            .enumerate()
680
+            .filter(|(_, r)| !r.item.is_separator())
681
+            .map(|(i, _)| i)
682
+            .collect();
683
+
684
+        if actionable_indices.is_empty() {
685
+            return;
686
+        }
687
+
688
+        let current_pos = self.focused_index
689
+            .and_then(|idx| actionable_indices.iter().position(|&i| i == idx))
690
+            .unwrap_or(0);
691
+
692
+        let new_pos = if delta > 0 {
693
+            (current_pos + 1) % actionable_indices.len()
694
+        } else if current_pos == 0 {
695
+            actionable_indices.len() - 1
696
+        } else {
697
+            current_pos - 1
698
+        };
699
+
700
+        self.focused_index = actionable_indices.get(new_pos).copied();
701
+        self.hovered_index = self.focused_index;
702
+    }
703
+
704
+    /// Move submenu focus by delta.
705
+    fn submenu_move_focus(&mut self, delta: i32) {
706
+        let actionable_indices: Vec<usize> = self.submenu_rendered_items
707
+            .iter()
708
+            .enumerate()
709
+            .filter(|(_, r)| !r.item.is_separator())
710
+            .map(|(i, _)| i)
711
+            .collect();
712
+
713
+        if actionable_indices.is_empty() {
714
+            return;
715
+        }
716
+
717
+        let current_pos = self.submenu_focused_index
718
+            .and_then(|idx| actionable_indices.iter().position(|&i| i == idx))
719
+            .unwrap_or(0);
720
+
721
+        let new_pos = if delta > 0 {
722
+            (current_pos + 1) % actionable_indices.len()
723
+        } else if current_pos == 0 {
724
+            actionable_indices.len() - 1
725
+        } else {
726
+            current_pos - 1
727
+        };
728
+
729
+        self.submenu_focused_index = actionable_indices.get(new_pos).copied();
730
+        self.submenu_hovered_index = self.submenu_focused_index;
731
+    }
732
+
733
+    /// Activate the focused item.
734
+    fn activate_focused(&mut self) -> Option<ContextMenuAction> {
735
+        // Check submenu first
736
+        if self.open_submenu_index.is_some() {
737
+            if let Some(item_idx) = self.submenu_focused_index {
738
+                if let Some(rendered) = self.submenu_rendered_items.get(item_idx) {
739
+                    if let MenuItem::Action { action, enabled, .. } = &rendered.item {
740
+                        if *enabled {
741
+                            let action = action.clone();
742
+                            self.hide();
743
+                            return Some(action);
744
+                        }
745
+                    }
746
+                }
747
+            }
748
+            return None;
749
+        }
750
+
751
+        // Check main menu
752
+        if let Some(idx) = self.focused_index {
753
+            if let Some(rendered) = self.rendered_items.get(idx) {
754
+                match &rendered.item {
755
+                    MenuItem::Action { action, enabled, .. } => {
756
+                        if *enabled {
757
+                            let action = action.clone();
758
+                            self.hide();
759
+                            return Some(action);
760
+                        }
761
+                    }
762
+                    MenuItem::Submenu { .. } => {
763
+                        // Enter opens submenu
764
+                        self.open_submenu(idx);
765
+                    }
766
+                    MenuItem::Separator => {}
767
+                }
768
+            }
769
+        }
770
+
771
+        None
772
+    }
773
+
774
+    /// Render the context menu.
775
+    pub fn render(&self, renderer: &Renderer) -> Result<()> {
776
+        if !self.visible {
777
+            return Ok(());
778
+        }
779
+
780
+        let theme = renderer.theme();
781
+
782
+        // Main menu background
783
+        renderer.fill_rounded_rect(self.menu_bounds, 6.0, theme.background)?;
784
+        renderer.stroke_rounded_rect(self.menu_bounds, 6.0, theme.border, 1.0)?;
785
+
786
+        // Render main menu items
787
+        for (i, rendered) in self.rendered_items.iter().enumerate() {
788
+            let focused = self.focused_index == Some(i);
789
+            let hovered = self.hovered_index == Some(i);
790
+            self.render_item(renderer, rendered, focused, hovered)?;
791
+        }
792
+
793
+        // Render open submenu if any
794
+        if self.open_submenu_index.is_some() {
795
+            self.render_submenu(renderer)?;
796
+        }
797
+
798
+        Ok(())
799
+    }
800
+
801
+    /// Render a single menu item.
802
+    fn render_item(&self, renderer: &Renderer, rendered: &RenderedItem, focused: bool, hovered: bool) -> Result<()> {
803
+        let theme = renderer.theme();
804
+
805
+        match &rendered.item {
806
+            MenuItem::Separator => {
807
+                let y = rendered.bounds.y + rendered.bounds.height as i32 / 2;
808
+                renderer.line(
809
+                    (rendered.bounds.x + 4) as f64,
810
+                    y as f64,
811
+                    (rendered.bounds.x + rendered.bounds.width as i32 - 4) as f64,
812
+                    y as f64,
813
+                    theme.border,
814
+                    1.0,
815
+                )?;
816
+            }
817
+            MenuItem::Action { label, shortcut, enabled, .. } => {
818
+                // Background for hover/focus
819
+                if (focused || hovered) && *enabled {
820
+                    renderer.fill_rounded_rect(rendered.bounds, 4.0, theme.item_hover_background)?;
821
+                }
822
+
823
+                let text_color = if *enabled {
824
+                    theme.foreground
825
+                } else {
826
+                    theme.item_description
827
+                };
828
+
829
+                let text_style = TextStyle::new()
830
+                    .font_family(&theme.font_family)
831
+                    .font_size(theme.font_size)
832
+                    .color(text_color);
833
+
834
+                // Label
835
+                renderer.text(
836
+                    label,
837
+                    (rendered.bounds.x + 12) as f64,
838
+                    (rendered.bounds.y + 6) as f64,
839
+                    &text_style,
840
+                )?;
841
+
842
+                // Shortcut (right-aligned)
843
+                if let Some(shortcut) = shortcut {
844
+                    let shortcut_style = text_style.clone().color(theme.item_description);
845
+                    let shortcut_width = renderer.measure_text(shortcut, &shortcut_style)
846
+                        .map(|m| m.width as i32)
847
+                        .unwrap_or(60);
848
+                    let shortcut_x = rendered.bounds.x + rendered.bounds.width as i32 - shortcut_width - 12;
849
+                    renderer.text(
850
+                        shortcut,
851
+                        shortcut_x as f64,
852
+                        (rendered.bounds.y + 6) as f64,
853
+                        &shortcut_style,
854
+                    )?;
855
+                }
856
+            }
857
+            MenuItem::Submenu { label, .. } => {
858
+                // Background for hover/focus
859
+                if focused || hovered {
860
+                    renderer.fill_rounded_rect(rendered.bounds, 4.0, theme.item_hover_background)?;
861
+                }
862
+
863
+                let text_style = TextStyle::new()
864
+                    .font_family(&theme.font_family)
865
+                    .font_size(theme.font_size)
866
+                    .color(theme.foreground);
867
+
868
+                // Label
869
+                renderer.text(
870
+                    label,
871
+                    (rendered.bounds.x + 12) as f64,
872
+                    (rendered.bounds.y + 6) as f64,
873
+                    &text_style,
874
+                )?;
875
+
876
+                // Arrow indicator (right-pointing)
877
+                let arrow_x = rendered.bounds.x + rendered.bounds.width as i32 - 16;
878
+                renderer.text(
879
+                    ">",
880
+                    arrow_x as f64,
881
+                    (rendered.bounds.y + 6) as f64,
882
+                    &text_style,
883
+                )?;
884
+            }
885
+        }
886
+
887
+        Ok(())
888
+    }
889
+
890
+    /// Render an open submenu.
891
+    fn render_submenu(&self, renderer: &Renderer) -> Result<()> {
892
+        let theme = renderer.theme();
893
+
894
+        let submenu_bounds = match &self.submenu_bounds {
895
+            Some(b) => *b,
896
+            None => return Ok(()),
897
+        };
898
+
899
+        // Submenu background
900
+        renderer.fill_rounded_rect(submenu_bounds, 6.0, theme.background)?;
901
+        renderer.stroke_rounded_rect(submenu_bounds, 6.0, theme.border, 1.0)?;
902
+
903
+        // Render submenu items
904
+        for (i, rendered) in self.submenu_rendered_items.iter().enumerate() {
905
+            let focused = self.submenu_focused_index == Some(i);
906
+            let hovered = self.submenu_hovered_index == Some(i);
907
+            self.render_item(renderer, rendered, focused, hovered)?;
908
+        }
909
+
910
+        Ok(())
911
+    }
912
+}
garfield/src/ui/mod.rsmodified
@@ -3,6 +3,7 @@
33
 pub mod address_bar;
44
 pub mod breadcrumb;
55
 pub mod column_view;
6
+pub mod context_menu;
67
 pub mod dialog;
78
 pub mod grid_view;
89
 pub mod help_modal;
@@ -15,6 +16,7 @@ pub mod tab_bar;
1516
 pub mod toolbar;
1617
 
1718
 pub use address_bar::AddressBar;
19
+pub use context_menu::{ContextMenu, ContextMenuAction, ContextType};
1820
 pub use dialog::{ConfirmDialog, ConflictAction, ConflictDialog, DialogResult, ProgressDialog, ProgressInfo};
1921
 pub use breadcrumb::Breadcrumb;
2022
 pub use column_view::{ColumnClickResult, ColumnView};
garfield/src/ui/tab.rsmodified
@@ -271,6 +271,16 @@ impl Tab {
271271
         self.refresh();
272272
     }
273273
 
274
+    /// Get current sort order.
275
+    pub fn sort_order(&self) -> SortOrder {
276
+        self.sort_order
277
+    }
278
+
279
+    /// Get current sort direction.
280
+    pub fn sort_direction(&self) -> SortDirection {
281
+        self.sort_direction
282
+    }
283
+
274284
     // === Navigation (keyboard) ===
275285
 
276286
     pub fn select_prev(&mut self) {