@@ -1,9 +1,10 @@ |
| 1 | 1 | //! Picker toolbar component with Accept/Cancel buttons. |
| 2 | 2 | //! |
| 3 | 3 | //! This toolbar replaces the normal toolbar when garfield runs in picker mode. |
| 4 | +//! In save mode, also includes a filename textbox. |
| 4 | 5 | |
| 5 | 6 | use anyhow::Result; |
| 6 | | -use gartk_core::{Point, Rect}; |
| 7 | +use gartk_core::{Key, Point, Rect}; |
| 7 | 8 | use gartk_render::{Renderer, TextStyle}; |
| 8 | 9 | |
| 9 | 10 | /// Height of the picker toolbar (same as normal toolbar). |
@@ -21,6 +22,9 @@ const PADDING: i32 = 8; |
| 21 | 22 | /// Gap between buttons. |
| 22 | 23 | const BUTTON_GAP: i32 = 12; |
| 23 | 24 | |
| 25 | +/// Filename textbox minimum width. |
| 26 | +const FILENAME_MIN_WIDTH: u32 = 200; |
| 27 | + |
| 24 | 28 | /// Picker toolbar click result. |
| 25 | 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 26 | 30 | pub enum PickerToolbarClick { |
@@ -46,16 +50,28 @@ pub struct PickerToolbar { |
| 46 | 50 | cancel_bounds: Rect, |
| 47 | 51 | /// Filter text bounds (for hover detection). |
| 48 | 52 | filter_bounds: Rect, |
| 49 | | - /// Hovered button (0 = accept, 1 = cancel). |
| 53 | + /// Hovered button (0 = accept, 1 = cancel, 2 = filename). |
| 50 | 54 | hovered: Option<usize>, |
| 51 | 55 | /// Whether filter text is hovered. |
| 52 | 56 | 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). |
| 54 | 58 | focused: usize, |
| 55 | 59 | /// Whether accept button is enabled (has valid selection). |
| 56 | 60 | accept_enabled: bool, |
| 57 | 61 | /// Filter description shown in toolbar (full text). |
| 58 | 62 | 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>, |
| 59 | 75 | } |
| 60 | 76 | |
| 61 | 77 | impl PickerToolbar { |
@@ -73,6 +89,38 @@ impl PickerToolbar { |
| 73 | 89 | focused: 0, |
| 74 | 90 | accept_enabled: false, |
| 75 | 91 | 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 |
| 76 | 124 | }; |
| 77 | 125 | toolbar.layout(); |
| 78 | 126 | toolbar |
@@ -95,10 +143,20 @@ impl PickerToolbar { |
| 95 | 143 | let accept_x = cancel_x - BUTTON_WIDTH as i32 - BUTTON_GAP; |
| 96 | 144 | self.accept_bounds = Rect::new(accept_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT); |
| 97 | 145 | |
| 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 | + } |
| 102 | 160 | } |
| 103 | 161 | |
| 104 | 162 | /// Get max width available for filter text. |
@@ -136,6 +194,8 @@ impl PickerToolbar { |
| 136 | 194 | Some(0) |
| 137 | 195 | } else if self.cancel_bounds.contains_point(pos) { |
| 138 | 196 | Some(1) |
| 197 | + } else if self.save_mode && self.filename_bounds.contains_point(pos) { |
| 198 | + Some(2) |
| 139 | 199 | } else { |
| 140 | 200 | None |
| 141 | 201 | }; |
@@ -170,29 +230,151 @@ impl PickerToolbar { |
| 170 | 230 | } |
| 171 | 231 | |
| 172 | 232 | /// 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 { |
| 174 | 234 | if self.accept_bounds.contains_point(pos) && self.accept_enabled { |
| 235 | + self.filename_editing = false; |
| 175 | 236 | PickerToolbarClick::Accept |
| 176 | 237 | } else if self.cancel_bounds.contains_point(pos) { |
| 238 | + self.filename_editing = false; |
| 177 | 239 | 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 |
| 178 | 248 | } else { |
| 249 | + // Click elsewhere stops editing |
| 250 | + self.filename_editing = false; |
| 179 | 251 | PickerToolbarClick::None |
| 180 | 252 | } |
| 181 | 253 | } |
| 182 | 254 | |
| 183 | | - /// Cycle focus between buttons. |
| 255 | + /// Cycle focus between elements. |
| 184 | 256 | 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 | + } |
| 186 | 268 | } |
| 187 | 269 | |
| 188 | | - /// Activate focused button. |
| 270 | + /// Activate focused element. |
| 189 | 271 | pub fn activate_focused(&self) -> PickerToolbarClick { |
| 190 | 272 | if self.focused == 0 && self.accept_enabled { |
| 191 | 273 | PickerToolbarClick::Accept |
| 192 | 274 | } else if self.focused == 1 { |
| 193 | 275 | PickerToolbarClick::Cancel |
| 194 | 276 | } 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; |
| 196 | 378 | } |
| 197 | 379 | } |
| 198 | 380 | |
@@ -208,8 +390,11 @@ impl PickerToolbar { |
| 208 | 390 | // Toolbar background |
| 209 | 391 | renderer.fill_rect(self.bounds, theme.item_background)?; |
| 210 | 392 | |
| 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) |
| 213 | 398 | let text_style = TextStyle::new() |
| 214 | 399 | .font_family(&theme.font_family) |
| 215 | 400 | .font_size(theme.font_size - 1.0) |
@@ -292,6 +477,122 @@ impl PickerToolbar { |
| 292 | 477 | Ok(()) |
| 293 | 478 | } |
| 294 | 479 | |
| 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 | + |
| 295 | 596 | /// Truncate text with ellipsis if it exceeds max width. |
| 296 | 597 | fn truncate_with_ellipsis(&self, text: &str, max_width: f64, renderer: &Renderer, style: &TextStyle) -> Result<String> { |
| 297 | 598 | let metrics = renderer.measure_text(text, style)?; |