gardesk/garfield / 7762f04

Browse files

picker: add save mode with filename textbox

- Add --save and --save-filename CLI args for save dialogs
- Add SaveFile variant to PickerMode enum
- Add filename textbox to picker toolbar with editing support
- Portal spawn_save_picker passes filename to garfield
- Portal finds garfield in ~/.cargo/bin first for development
- Improved focus highlighting on filename textbox
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7762f040264fbe0b2d2b0e122ad7c4d50ee35a8a
Parents
a2a6ddf
Tree
f84ceba

4 changed files

StatusFile+-
M garfield-portal/src/file_chooser.rs 121 12
M garfield/src/app.rs 63 12
M garfield/src/main.rs 37 3
M garfield/src/ui/picker_toolbar.rs 315 14
garfield-portal/src/file_chooser.rsmodified
@@ -33,12 +33,15 @@ impl FileChooser {
3333
         filters: Vec<String>,
3434
         current_folder: Option<String>,
3535
     ) -> (u32, HashMap<String, Value<'static>>) {
36
-        // Use full path to ensure we get the right garfield binary
37
-        // (user may have old version in ~/.cargo/bin before /usr/local/bin in PATH)
38
-        let garfield_path = if std::path::Path::new("/usr/local/bin/garfield").exists() {
39
-            "/usr/local/bin/garfield"
36
+        // Find garfield binary - prefer ~/.cargo/bin (development), then /usr/local/bin, then PATH
37
+        let home = std::env::var("HOME").unwrap_or_default();
38
+        let cargo_path = format!("{}/.cargo/bin/garfield", home);
39
+        let garfield_path = if std::path::Path::new(&cargo_path).exists() {
40
+            cargo_path
41
+        } else if std::path::Path::new("/usr/local/bin/garfield").exists() {
42
+            "/usr/local/bin/garfield".to_string()
4043
         } else {
41
-            "garfield" // Fall back to PATH lookup
44
+            "garfield".to_string() // Fall back to PATH lookup
4245
         };
4346
 
4447
         let mut cmd = Command::new(garfield_path);
@@ -190,6 +193,112 @@ impl FileChooser {
190193
             None
191194
         }
192195
     }
196
+
197
+    /// Extract current_name (suggested filename) from options for SaveFile.
198
+    fn parse_current_name(options: &HashMap<&str, Value<'_>>) -> Option<String> {
199
+        if let Some(Value::Str(s)) = options.get("current_name") {
200
+            Some(s.to_string())
201
+        } else {
202
+            None
203
+        }
204
+    }
205
+
206
+    /// Spawn garfield in save mode.
207
+    async fn spawn_save_picker(
208
+        &self,
209
+        handle: OwnedObjectPath,
210
+        title: &str,
211
+        suggested_filename: Option<String>,
212
+        current_folder: Option<String>,
213
+    ) -> (u32, HashMap<String, Value<'static>>) {
214
+        // Find garfield binary - prefer ~/.cargo/bin (development), then /usr/local/bin, then PATH
215
+        let home = std::env::var("HOME").unwrap_or_default();
216
+        let cargo_path = format!("{}/.cargo/bin/garfield", home);
217
+        let garfield_path = if std::path::Path::new(&cargo_path).exists() {
218
+            cargo_path
219
+        } else if std::path::Path::new("/usr/local/bin/garfield").exists() {
220
+            "/usr/local/bin/garfield".to_string()
221
+        } else {
222
+            "garfield".to_string()
223
+        };
224
+
225
+        let mut cmd = Command::new(&garfield_path);
226
+        cmd.arg("--picker");
227
+        cmd.arg("--save");
228
+
229
+        if let Some(filename) = suggested_filename {
230
+            cmd.arg("--save-filename").arg(&filename);
231
+        }
232
+
233
+        if !title.is_empty() {
234
+            cmd.arg("--title").arg(title);
235
+        }
236
+
237
+        if let Some(folder) = current_folder {
238
+            cmd.arg(&folder);
239
+        }
240
+
241
+        cmd.stdout(Stdio::piped());
242
+        cmd.stderr(Stdio::inherit());
243
+
244
+        tracing::info!("Spawning garfield save picker: {:?}", cmd);
245
+
246
+        let mut child = match cmd.spawn() {
247
+            Ok(c) => c,
248
+            Err(e) => {
249
+                tracing::error!("Failed to spawn garfield: {}", e);
250
+                return (ResponseCode::Error as u32, HashMap::new());
251
+            }
252
+        };
253
+
254
+        tracing::info!("garfield save picker spawned with PID {:?}", child.id());
255
+
256
+        let stdout = match child.stdout.take() {
257
+            Some(s) => s,
258
+            None => {
259
+                tracing::error!("Failed to get stdout from garfield");
260
+                return (ResponseCode::Error as u32, HashMap::new());
261
+            }
262
+        };
263
+
264
+        self.request_manager.add(handle.clone(), child).await;
265
+
266
+        tracing::info!("Waiting for garfield save picker to complete...");
267
+        let reader = BufReader::new(stdout);
268
+        let mut lines = reader.lines();
269
+        let mut paths = Vec::new();
270
+
271
+        while let Ok(Some(line)) = lines.next_line().await {
272
+            if !line.is_empty() {
273
+                tracing::debug!("garfield output: {}", line);
274
+                paths.push(line);
275
+            }
276
+        }
277
+
278
+        tracing::info!("garfield save picker completed, got {} paths", paths.len());
279
+        for (i, path) in paths.iter().enumerate() {
280
+            tracing::info!("  path[{}]: {:?}", i, path);
281
+        }
282
+
283
+        let request = self.request_manager.remove(&handle.as_ref()).await;
284
+
285
+        if let Some(req) = &request {
286
+            if req.cancelled {
287
+                tracing::info!("Request was cancelled");
288
+                return (ResponseCode::Cancelled as u32, HashMap::new());
289
+            }
290
+        }
291
+
292
+        if paths.is_empty() {
293
+            tracing::info!("No path selected, treating as cancelled");
294
+            (ResponseCode::Cancelled as u32, HashMap::new())
295
+        } else {
296
+            tracing::info!("Returning save path: {:?}", paths[0]);
297
+            let response = build_file_chooser_response(paths);
298
+            tracing::info!("Response: {:?}", response);
299
+            (ResponseCode::Success as u32, response)
300
+        }
301
+    }
193302
 }
194303
 
195304
 #[interface(name = "org.freedesktop.impl.portal.FileChooser")]
@@ -263,23 +372,23 @@ impl FileChooser {
263372
         options: HashMap<&str, Value<'_>>,
264373
     ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
265374
         tracing::info!("SaveFile request: handle={}, title={}", handle, title);
375
+        tracing::debug!("SaveFile options: {:?}", options);
266376
 
267
-        // For now, save dialogs work like open dialogs but for directories
268
-        // A full implementation would show a save dialog with filename input
269377
         let handle_owned: OwnedObjectPath = handle.into();
270378
         let current_folder = Self::parse_current_folder(&options);
379
+        let suggested_filename = Self::parse_current_name(&options);
380
+
381
+        tracing::info!("SaveFile: folder={:?}, filename={:?}", current_folder, suggested_filename);
271382
 
272383
         let request = Request::new(handle_owned.clone(), self.request_manager.clone());
273384
         server.at(handle_owned.as_ref(), request).await
274385
             .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
275386
 
276
-        // For save, we pick a directory and the caller handles the filename
277
-        let result = self.spawn_picker(
387
+        // Spawn picker in save mode with suggested filename
388
+        let result = self.spawn_save_picker(
278389
             handle_owned.clone(),
279390
             title,
280
-            true, // directory mode for save location
281
-            false,
282
-            Vec::new(),
391
+            suggested_filename,
283392
             current_folder,
284393
         ).await;
285394
 
garfield/src/app.rsmodified
@@ -212,13 +212,23 @@ impl App {
212212
                 width - sidebar_w,
213213
                 PICKER_TOOLBAR_HEIGHT,
214214
             );
215
-            let mut pt = PickerToolbar::new(picker_toolbar_bounds, picker_config.accept_label.clone());
216
-            // Set filter description if we have filters
217
-            let filters = picker_config.mode.filters();
218
-            if !filters.is_empty() {
219
-                let desc = format!("Filter: {}", filters.join(", "));
220
-                pt.set_filter_description(Some(desc));
221
-            }
215
+            let pt = if picker_config.mode.is_save_mode() {
216
+                // Save mode - create with filename textbox
217
+                let suggested = picker_config.mode.suggested_filename()
218
+                    .unwrap_or("untitled")
219
+                    .to_string();
220
+                PickerToolbar::new_save_mode(picker_toolbar_bounds, picker_config.accept_label.clone(), suggested)
221
+            } else {
222
+                // Open mode - create with filters
223
+                let mut pt = PickerToolbar::new(picker_toolbar_bounds, picker_config.accept_label.clone());
224
+                // Set filter description if we have filters
225
+                let filters = picker_config.mode.filters();
226
+                if !filters.is_empty() {
227
+                    let desc = format!("Filter: {}", filters.join(", "));
228
+                    pt.set_filter_description(Some(desc));
229
+                }
230
+                pt
231
+            };
222232
             Some(pt)
223233
         } else {
224234
             None
@@ -442,11 +452,33 @@ impl App {
442452
 
443453
     /// Output picker selection and exit.
444454
     fn accept_picker_selection(&mut self) {
445
-        let paths = self.get_picker_selection();
455
+        // Check if we're in save mode
456
+        if self.picker_config.mode.is_save_mode() {
457
+            // Get filename from picker toolbar
458
+            let filename = self.picker_toolbar
459
+                .as_ref()
460
+                .map(|pt| pt.filename().to_string())
461
+                .unwrap_or_else(|| "untitled".to_string());
462
+
463
+            if filename.is_empty() {
464
+                // Don't accept with empty filename
465
+                return;
466
+            }
446467
 
447
-        // Output paths to stdout (one per line)
448
-        for path in &paths {
449
-            println!("{}", path.display());
468
+            // Get current directory from focused pane
469
+            if let Some(pane) = self.focused_pane() {
470
+                if let Some(tab) = pane.active_tab() {
471
+                    let current_dir = tab.current_path();
472
+                    let full_path = current_dir.join(&filename);
473
+                    println!("{}", full_path.display());
474
+                }
475
+            }
476
+        } else {
477
+            // Open mode - output selected paths
478
+            let paths = self.get_picker_selection();
479
+            for path in &paths {
480
+                println!("{}", path.display());
481
+            }
450482
         }
451483
 
452484
         self.should_quit = true;
@@ -700,7 +732,7 @@ impl App {
700732
         }
701733
 
702734
         // Check picker toolbar clicks (if in picker mode)
703
-        if let Some(picker_toolbar) = &self.picker_toolbar {
735
+        if let Some(picker_toolbar) = &mut self.picker_toolbar {
704736
             match picker_toolbar.on_click(pos) {
705737
                 PickerToolbarClick::Accept => {
706738
                     self.accept_picker_selection();
@@ -1220,6 +1252,25 @@ impl App {
12201252
             }
12211253
         }
12221254
 
1255
+        // Handle picker filename textbox input (save mode)
1256
+        if let Some(picker_toolbar) = &mut self.picker_toolbar {
1257
+            if picker_toolbar.is_editing_filename() {
1258
+                if *key == Key::Return {
1259
+                    // Enter accepts the save
1260
+                    self.accept_picker_selection();
1261
+                    return;
1262
+                }
1263
+                if *key == Key::Tab {
1264
+                    // Tab cycles focus
1265
+                    picker_toolbar.cycle_focus();
1266
+                    return;
1267
+                }
1268
+                if picker_toolbar.handle_key(key) {
1269
+                    return;
1270
+                }
1271
+            }
1272
+        }
1273
+
12231274
         // Handle rename input
12241275
         if self.is_renaming() {
12251276
             match key {
garfield/src/main.rsmodified
@@ -35,9 +35,17 @@ pub struct Args {
3535
     #[arg(long, short = 't', requires = "picker")]
3636
     pub title: Option<String>,
3737
 
38
-    /// Custom accept button text (default: "Open")
38
+    /// Custom accept button text (default: "Open" or "Save")
3939
     #[arg(long, requires = "picker")]
4040
     pub accept_label: Option<String>,
41
+
42
+    /// Enable save mode (for SaveFile portal requests)
43
+    #[arg(long, requires = "picker")]
44
+    pub save: bool,
45
+
46
+    /// Suggested filename for save mode
47
+    #[arg(long, requires = "save")]
48
+    pub save_filename: Option<String>,
4149
 }
4250
 
4351
 /// Picker mode configuration parsed from CLI args.
@@ -54,6 +62,11 @@ pub enum PickerMode {
5462
     OpenDirectory {
5563
         multiple: bool,
5664
     },
65
+    /// Save file picker (with filename input).
66
+    SaveFile {
67
+        /// Suggested filename from the portal.
68
+        suggested_filename: String,
69
+    },
5770
 }
5871
 
5972
 impl PickerMode {
@@ -68,6 +81,7 @@ impl PickerMode {
6881
             PickerMode::None => true,
6982
             PickerMode::OpenFile { multiple, .. } => *multiple,
7083
             PickerMode::OpenDirectory { multiple } => *multiple,
84
+            PickerMode::SaveFile { .. } => false,
7185
         }
7286
     }
7387
 
@@ -83,6 +97,19 @@ impl PickerMode {
8397
     pub fn is_directory_mode(&self) -> bool {
8498
         matches!(self, PickerMode::OpenDirectory { .. })
8599
     }
100
+
101
+    /// Whether this is save mode.
102
+    pub fn is_save_mode(&self) -> bool {
103
+        matches!(self, PickerMode::SaveFile { .. })
104
+    }
105
+
106
+    /// Get suggested filename for save mode.
107
+    pub fn suggested_filename(&self) -> Option<&str> {
108
+        match self {
109
+            PickerMode::SaveFile { suggested_filename } => Some(suggested_filename),
110
+            _ => None,
111
+        }
112
+    }
86113
 }
87114
 
88115
 /// Picker configuration derived from Args.
@@ -100,7 +127,11 @@ impl PickerConfig {
100127
     /// Create from command line arguments.
101128
     pub fn from_args(args: &Args) -> Self {
102129
         let mode = if args.picker {
103
-            if args.directory {
130
+            if args.save {
131
+                PickerMode::SaveFile {
132
+                    suggested_filename: args.save_filename.clone().unwrap_or_else(|| "untitled".to_string()),
133
+                }
134
+            } else if args.directory {
104135
                 PickerMode::OpenDirectory {
105136
                     multiple: args.multiple,
106137
                 }
@@ -118,10 +149,13 @@ impl PickerConfig {
118149
             PickerMode::None
119150
         };
120151
 
152
+        // Default button label depends on mode
153
+        let default_label = if args.save { "Save" } else { "Open" };
154
+
121155
         Self {
122156
             mode,
123157
             title: args.title.clone(),
124
-            accept_label: args.accept_label.clone().unwrap_or_else(|| "Open".to_string()),
158
+            accept_label: args.accept_label.clone().unwrap_or_else(|| default_label.to_string()),
125159
         }
126160
     }
127161
 
garfield/src/ui/picker_toolbar.rsmodified
@@ -1,9 +1,10 @@
11
 //! Picker toolbar component with Accept/Cancel buttons.
22
 //!
33
 //! This toolbar replaces the normal toolbar when garfield runs in picker mode.
4
+//! In save mode, also includes a filename textbox.
45
 
56
 use anyhow::Result;
6
-use gartk_core::{Point, Rect};
7
+use gartk_core::{Key, Point, Rect};
78
 use gartk_render::{Renderer, TextStyle};
89
 
910
 /// Height of the picker toolbar (same as normal toolbar).
@@ -21,6 +22,9 @@ const PADDING: i32 = 8;
2122
 /// Gap between buttons.
2223
 const BUTTON_GAP: i32 = 12;
2324
 
25
+/// Filename textbox minimum width.
26
+const FILENAME_MIN_WIDTH: u32 = 200;
27
+
2428
 /// Picker toolbar click result.
2529
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2630
 pub enum PickerToolbarClick {
@@ -46,16 +50,28 @@ pub struct PickerToolbar {
4650
     cancel_bounds: Rect,
4751
     /// Filter text bounds (for hover detection).
4852
     filter_bounds: Rect,
49
-    /// Hovered button (0 = accept, 1 = cancel).
53
+    /// Hovered button (0 = accept, 1 = cancel, 2 = filename).
5054
     hovered: Option<usize>,
5155
     /// Whether filter text is hovered.
5256
     filter_hovered: bool,
53
-    /// Focused button for keyboard navigation (0 = accept, 1 = cancel).
57
+    /// Focused button for keyboard navigation (0 = accept, 1 = cancel, 2 = filename).
5458
     focused: usize,
5559
     /// Whether accept button is enabled (has valid selection).
5660
     accept_enabled: bool,
5761
     /// Filter description shown in toolbar (full text).
5862
     filter_description: Option<String>,
63
+    /// Whether this is save mode (shows filename textbox).
64
+    save_mode: bool,
65
+    /// Filename for save mode.
66
+    filename: String,
67
+    /// Filename textbox bounds.
68
+    filename_bounds: Rect,
69
+    /// Whether filename textbox is being edited.
70
+    filename_editing: bool,
71
+    /// Cursor position in filename (character index).
72
+    filename_cursor: usize,
73
+    /// Selection start in filename (if different from cursor, text is selected).
74
+    filename_selection_start: Option<usize>,
5975
 }
6076
 
6177
 impl PickerToolbar {
@@ -73,6 +89,38 @@ impl PickerToolbar {
7389
             focused: 0,
7490
             accept_enabled: false,
7591
             filter_description: None,
92
+            save_mode: false,
93
+            filename: String::new(),
94
+            filename_bounds: Rect::default(),
95
+            filename_editing: false,
96
+            filename_cursor: 0,
97
+            filename_selection_start: None,
98
+        };
99
+        toolbar.layout();
100
+        toolbar
101
+    }
102
+
103
+    /// Create a new picker toolbar for save mode with suggested filename.
104
+    pub fn new_save_mode(bounds: Rect, accept_label: String, suggested_filename: String) -> Self {
105
+        let cursor_pos = suggested_filename.len();
106
+        let mut toolbar = Self {
107
+            bounds,
108
+            accept_label,
109
+            cancel_label: "Cancel".to_string(),
110
+            accept_bounds: Rect::default(),
111
+            cancel_bounds: Rect::default(),
112
+            filter_bounds: Rect::default(),
113
+            hovered: None,
114
+            filter_hovered: false,
115
+            focused: 2, // Start focused on filename
116
+            accept_enabled: true, // Enable by default in save mode
117
+            filter_description: None,
118
+            save_mode: true,
119
+            filename: suggested_filename,
120
+            filename_bounds: Rect::default(),
121
+            filename_editing: true, // Start editing
122
+            filename_cursor: cursor_pos,
123
+            filename_selection_start: Some(0), // Select all
76124
         };
77125
         toolbar.layout();
78126
         toolbar
@@ -95,10 +143,20 @@ impl PickerToolbar {
95143
         let accept_x = cancel_x - BUTTON_WIDTH as i32 - BUTTON_GAP;
96144
         self.accept_bounds = Rect::new(accept_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT);
97145
 
98
-        // Filter text area (left side, up to accept button)
99
-        let filter_x = self.bounds.x + PADDING;
100
-        let filter_width = (accept_x - BUTTON_GAP - filter_x).max(0) as u32;
101
-        self.filter_bounds = Rect::new(filter_x, self.bounds.y, filter_width, self.bounds.height);
146
+        if self.save_mode {
147
+            // Filename textbox (left side, takes available space)
148
+            let filename_x = self.bounds.x + PADDING;
149
+            let available_width = (accept_x - BUTTON_GAP - filename_x).max(FILENAME_MIN_WIDTH as i32) as u32;
150
+            self.filename_bounds = Rect::new(filename_x, button_y, available_width, BUTTON_HEIGHT);
151
+            // No filter area in save mode
152
+            self.filter_bounds = Rect::default();
153
+        } else {
154
+            // Filter text area (left side, up to accept button)
155
+            let filter_x = self.bounds.x + PADDING;
156
+            let filter_width = (accept_x - BUTTON_GAP - filter_x).max(0) as u32;
157
+            self.filter_bounds = Rect::new(filter_x, self.bounds.y, filter_width, self.bounds.height);
158
+            self.filename_bounds = Rect::default();
159
+        }
102160
     }
103161
 
104162
     /// Get max width available for filter text.
@@ -136,6 +194,8 @@ impl PickerToolbar {
136194
             Some(0)
137195
         } else if self.cancel_bounds.contains_point(pos) {
138196
             Some(1)
197
+        } else if self.save_mode && self.filename_bounds.contains_point(pos) {
198
+            Some(2)
139199
         } else {
140200
             None
141201
         };
@@ -170,29 +230,151 @@ impl PickerToolbar {
170230
     }
171231
 
172232
     /// Handle click. Returns the action if a button was clicked.
173
-    pub fn on_click(&self, pos: Point) -> PickerToolbarClick {
233
+    pub fn on_click(&mut self, pos: Point) -> PickerToolbarClick {
174234
         if self.accept_bounds.contains_point(pos) && self.accept_enabled {
235
+            self.filename_editing = false;
175236
             PickerToolbarClick::Accept
176237
         } else if self.cancel_bounds.contains_point(pos) {
238
+            self.filename_editing = false;
177239
             PickerToolbarClick::Cancel
240
+        } else if self.save_mode && self.filename_bounds.contains_point(pos) {
241
+            // Click on filename textbox - start editing
242
+            self.filename_editing = true;
243
+            self.focused = 2;
244
+            // Position cursor at click point (simplified: just move to end)
245
+            self.filename_cursor = self.filename.len();
246
+            self.filename_selection_start = None;
247
+            PickerToolbarClick::None
178248
         } else {
249
+            // Click elsewhere stops editing
250
+            self.filename_editing = false;
179251
             PickerToolbarClick::None
180252
         }
181253
     }
182254
 
183
-    /// Cycle focus between buttons.
255
+    /// Cycle focus between elements.
184256
     pub fn cycle_focus(&mut self) {
185
-        self.focused = 1 - self.focused;
257
+        if self.save_mode {
258
+            // Cycle: filename (2) -> accept (0) -> cancel (1) -> filename
259
+            self.focused = match self.focused {
260
+                2 => 0,
261
+                0 => 1,
262
+                _ => 2,
263
+            };
264
+            self.filename_editing = self.focused == 2;
265
+        } else {
266
+            self.focused = 1 - self.focused;
267
+        }
186268
     }
187269
 
188
-    /// Activate focused button.
270
+    /// Activate focused element.
189271
     pub fn activate_focused(&self) -> PickerToolbarClick {
190272
         if self.focused == 0 && self.accept_enabled {
191273
             PickerToolbarClick::Accept
192274
         } else if self.focused == 1 {
193275
             PickerToolbarClick::Cancel
194276
         } else {
195
-            PickerToolbarClick::None
277
+            // Focused on filename - Enter should accept if valid
278
+            if self.save_mode && !self.filename.is_empty() {
279
+                PickerToolbarClick::Accept
280
+            } else {
281
+                PickerToolbarClick::None
282
+            }
283
+        }
284
+    }
285
+
286
+    /// Whether filename textbox is being edited.
287
+    pub fn is_editing_filename(&self) -> bool {
288
+        self.save_mode && self.filename_editing
289
+    }
290
+
291
+    /// Get the current filename.
292
+    pub fn filename(&self) -> &str {
293
+        &self.filename
294
+    }
295
+
296
+    /// Handle keyboard input for filename editing. Returns true if handled.
297
+    pub fn handle_key(&mut self, key: &Key) -> bool {
298
+        if !self.filename_editing {
299
+            return false;
300
+        }
301
+
302
+        match key {
303
+            Key::Char(c) => {
304
+                // Don't allow path separators in filename
305
+                if *c != '/' && *c != '\\' && *c != '\0' {
306
+                    // Delete selection first if any
307
+                    self.delete_selection();
308
+                    self.filename.insert(self.filename_cursor, *c);
309
+                    self.filename_cursor += 1;
310
+                }
311
+                true
312
+            }
313
+            Key::Backspace => {
314
+                if self.filename_selection_start.is_some() {
315
+                    self.delete_selection();
316
+                } else if self.filename_cursor > 0 {
317
+                    self.filename_cursor -= 1;
318
+                    self.filename.remove(self.filename_cursor);
319
+                }
320
+                true
321
+            }
322
+            Key::Delete => {
323
+                if self.filename_selection_start.is_some() {
324
+                    self.delete_selection();
325
+                } else if self.filename_cursor < self.filename.len() {
326
+                    self.filename.remove(self.filename_cursor);
327
+                }
328
+                true
329
+            }
330
+            Key::Left => {
331
+                if self.filename_cursor > 0 {
332
+                    self.filename_cursor -= 1;
333
+                }
334
+                self.filename_selection_start = None;
335
+                true
336
+            }
337
+            Key::Right => {
338
+                if self.filename_cursor < self.filename.len() {
339
+                    self.filename_cursor += 1;
340
+                }
341
+                self.filename_selection_start = None;
342
+                true
343
+            }
344
+            Key::Home => {
345
+                self.filename_cursor = 0;
346
+                self.filename_selection_start = None;
347
+                true
348
+            }
349
+            Key::End => {
350
+                self.filename_cursor = self.filename.len();
351
+                self.filename_selection_start = None;
352
+                true
353
+            }
354
+            _ => false,
355
+        }
356
+    }
357
+
358
+    /// Delete selected text.
359
+    fn delete_selection(&mut self) {
360
+        if let Some(start) = self.filename_selection_start.take() {
361
+            let (from, to) = if start < self.filename_cursor {
362
+                (start, self.filename_cursor)
363
+            } else {
364
+                (self.filename_cursor, start)
365
+            };
366
+            self.filename.drain(from..to);
367
+            self.filename_cursor = from;
368
+        }
369
+    }
370
+
371
+    /// Select all text in filename.
372
+    pub fn select_all(&mut self) {
373
+        if self.save_mode {
374
+            self.filename_selection_start = Some(0);
375
+            self.filename_cursor = self.filename.len();
376
+            self.filename_editing = true;
377
+            self.focused = 2;
196378
         }
197379
     }
198380
 
@@ -208,8 +390,11 @@ impl PickerToolbar {
208390
         // Toolbar background
209391
         renderer.fill_rect(self.bounds, theme.item_background)?;
210392
 
211
-        // Filter description (left side, truncated with ellipsis)
212
-        if let Some(desc) = &self.filter_description {
393
+        // Save mode: filename textbox
394
+        if self.save_mode {
395
+            self.render_filename_textbox(renderer)?;
396
+        } else if let Some(desc) = &self.filter_description {
397
+            // Filter description (left side, truncated with ellipsis)
213398
             let text_style = TextStyle::new()
214399
                 .font_family(&theme.font_family)
215400
                 .font_size(theme.font_size - 1.0)
@@ -292,6 +477,122 @@ impl PickerToolbar {
292477
         Ok(())
293478
     }
294479
 
480
+    /// Render the filename textbox for save mode.
481
+    fn render_filename_textbox(&self, renderer: &Renderer) -> Result<()> {
482
+        let theme = renderer.theme();
483
+        let filename_focused = self.focused == 2;
484
+        let filename_hovered = self.hovered == Some(2);
485
+
486
+        // Textbox background - brighter when editing/focused
487
+        let bg_color = if self.filename_editing {
488
+            theme.background
489
+        } else if filename_focused || filename_hovered {
490
+            theme.item_hover_background
491
+        } else {
492
+            theme.input_background
493
+        };
494
+
495
+        renderer.fill_rounded_rect(self.filename_bounds, 4.0, bg_color)?;
496
+
497
+        // Border - thick accent color when editing, thinner when just focused
498
+        if self.filename_editing {
499
+            // Editing: prominent accent border
500
+            renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.selection_background, 2.0)?;
501
+            // Inner glow effect
502
+            let inner = Rect::new(
503
+                self.filename_bounds.x + 1,
504
+                self.filename_bounds.y + 1,
505
+                self.filename_bounds.width.saturating_sub(2),
506
+                self.filename_bounds.height.saturating_sub(2),
507
+            );
508
+            renderer.stroke_rounded_rect(inner, 3.0, theme.selection_background.with_alpha(0.3), 1.0)?;
509
+        } else if filename_focused {
510
+            // Focused but not editing: white/foreground border
511
+            renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.foreground, 2.0)?;
512
+        } else {
513
+            // Normal: subtle border
514
+            renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.border, 1.0)?;
515
+        }
516
+
517
+        // "Filename:" label
518
+        let label = "Filename:";
519
+        let label_style = TextStyle::new()
520
+            .font_family(&theme.font_family)
521
+            .font_size(theme.font_size - 1.0)
522
+            .color(theme.item_foreground);
523
+
524
+        let label_metrics = renderer.measure_text(label, &label_style)?;
525
+        let label_x = self.filename_bounds.x + 8;
526
+        let label_y = self.filename_bounds.y + (self.filename_bounds.height as i32 - label_metrics.height as i32) / 2;
527
+        renderer.text(label, label_x as f64, label_y as f64, &label_style)?;
528
+
529
+        // Text content area (after label)
530
+        let text_padding = 8;
531
+        let text_x_start = label_x + label_metrics.width as i32 + text_padding;
532
+        let text_max_width = (self.filename_bounds.x + self.filename_bounds.width as i32 - text_x_start - text_padding) as u32;
533
+
534
+        let text_style = TextStyle::new()
535
+            .font_family(&theme.font_family)
536
+            .font_size(theme.font_size)
537
+            .color(theme.foreground);
538
+
539
+        // Selection highlight
540
+        if let Some(sel_start) = self.filename_selection_start {
541
+            if sel_start != self.filename_cursor {
542
+                let (from, to) = if sel_start < self.filename_cursor {
543
+                    (sel_start, self.filename_cursor)
544
+                } else {
545
+                    (self.filename_cursor, sel_start)
546
+                };
547
+
548
+                // Measure text up to selection start and end
549
+                let before_sel = &self.filename[..from];
550
+                let selection = &self.filename[from..to];
551
+
552
+                let before_width = if before_sel.is_empty() {
553
+                    0
554
+                } else {
555
+                    renderer.measure_text(before_sel, &text_style)?.width
556
+                };
557
+                let sel_width = renderer.measure_text(selection, &text_style)?.width;
558
+
559
+                let sel_x = text_x_start + before_width as i32;
560
+                let sel_rect = Rect::new(
561
+                    sel_x,
562
+                    self.filename_bounds.y + 4,
563
+                    sel_width.min(text_max_width),
564
+                    self.filename_bounds.height - 8,
565
+                );
566
+                renderer.fill_rect(sel_rect, theme.selection_background.with_alpha(0.4))?;
567
+            }
568
+        }
569
+
570
+        // Filename text
571
+        let text_y = self.filename_bounds.y + (self.filename_bounds.height as i32 - theme.font_size as i32) / 2;
572
+        renderer.text(&self.filename, text_x_start as f64, text_y as f64, &text_style)?;
573
+
574
+        // Cursor when editing
575
+        if self.filename_editing {
576
+            let cursor_text = &self.filename[..self.filename_cursor];
577
+            let cursor_offset = if cursor_text.is_empty() {
578
+                0
579
+            } else {
580
+                renderer.measure_text(cursor_text, &text_style)?.width
581
+            };
582
+
583
+            let cursor_x = text_x_start + cursor_offset as i32;
584
+            let cursor_rect = Rect::new(
585
+                cursor_x,
586
+                self.filename_bounds.y + 6,
587
+                2,
588
+                self.filename_bounds.height - 12,
589
+            );
590
+            renderer.fill_rect(cursor_rect, theme.foreground)?;
591
+        }
592
+
593
+        Ok(())
594
+    }
595
+
295596
     /// Truncate text with ellipsis if it exceeds max width.
296597
     fn truncate_with_ellipsis(&self, text: &str, max_width: f64, renderer: &Renderer, style: &TextStyle) -> Result<String> {
297598
         let metrics = renderer.measure_text(text, style)?;