@@ -1,6 +1,7 @@ |
| 1 | 1 | //! List view component for displaying directory contents. |
| 2 | 2 | |
| 3 | 3 | use crate::core::{EntryType, FileEntry, SortDirection, SortOrder}; |
| 4 | +use crate::ui::tab::RenameState; |
| 4 | 5 | use gartk_core::{Color, Modifiers, Point, Rect}; |
| 5 | 6 | use gartk_render::{Renderer, TextStyle}; |
| 6 | 7 | use std::collections::HashSet; |
@@ -109,6 +110,11 @@ impl ListView { |
| 109 | 110 | visible.get(self.focused).copied() |
| 110 | 111 | } |
| 111 | 112 | |
| 113 | + /// Get the focused index. |
| 114 | + pub fn focused_index(&self) -> usize { |
| 115 | + self.focused |
| 116 | + } |
| 117 | + |
| 112 | 118 | /// Get all selected entries. |
| 113 | 119 | pub fn selected_entries(&self) -> Vec<&FileEntry> { |
| 114 | 120 | let visible = self.visible_entries(); |
@@ -490,7 +496,7 @@ impl ListView { |
| 490 | 496 | } |
| 491 | 497 | |
| 492 | 498 | /// Render the list view. |
| 493 | | - pub fn render(&self, renderer: &Renderer) -> anyhow::Result<()> { |
| 499 | + pub fn render(&self, renderer: &Renderer, rename_state: Option<&RenameState>) -> anyhow::Result<()> { |
| 494 | 500 | let theme = renderer.theme(); |
| 495 | 501 | let visible = self.visible_entries(); |
| 496 | 502 | let visible_rows = self.visible_rows(); |
@@ -512,6 +518,7 @@ impl ListView { |
| 512 | 518 | let actual_index = self.scroll_offset + i; |
| 513 | 519 | let is_selected = self.selected.contains(&actual_index); |
| 514 | 520 | let is_focused = actual_index == self.focused; |
| 521 | + let is_renaming = rename_state.map_or(false, |s| s.index == actual_index); |
| 515 | 522 | |
| 516 | 523 | // Row background |
| 517 | 524 | if is_selected { |
@@ -560,26 +567,35 @@ impl ListView { |
| 560 | 567 | EntryType::Symlink => "\u{1F517} ", |
| 561 | 568 | _ => "\u{1F4C4} ", |
| 562 | 569 | }; |
| 563 | | - let display_name = if entry.is_symlink { |
| 564 | | - if let Some(target) = &entry.symlink_target { |
| 565 | | - let target_str = target.to_string_lossy(); |
| 566 | | - // Truncate long targets |
| 567 | | - let target_display = if target_str.len() > 30 { |
| 568 | | - format!("...{}", &target_str[target_str.len()-27..]) |
| 570 | + |
| 571 | + let name_rect = |
| 572 | + Rect::new(row_rect.x + 8, row_rect.y, self.column_widths[0], ROW_HEIGHT); |
| 573 | + |
| 574 | + if is_renaming { |
| 575 | + // Render rename text field |
| 576 | + if let Some(state) = rename_state { |
| 577 | + self.render_rename_field(renderer, name_rect, state, &icon)?; |
| 578 | + } |
| 579 | + } else { |
| 580 | + let display_name = if entry.is_symlink { |
| 581 | + if let Some(target) = &entry.symlink_target { |
| 582 | + let target_str = target.to_string_lossy(); |
| 583 | + // Truncate long targets |
| 584 | + let target_display = if target_str.len() > 30 { |
| 585 | + format!("...{}", &target_str[target_str.len()-27..]) |
| 586 | + } else { |
| 587 | + target_str.to_string() |
| 588 | + }; |
| 589 | + format!("{}{} -> {}", icon, entry.name, target_display) |
| 569 | 590 | } else { |
| 570 | | - target_str.to_string() |
| 571 | | - }; |
| 572 | | - format!("{}{} -> {}", icon, entry.name, target_display) |
| 591 | + format!("{}{}", icon, entry.name) |
| 592 | + } |
| 573 | 593 | } else { |
| 574 | 594 | format!("{}{}", icon, entry.name) |
| 575 | | - } |
| 576 | | - } else { |
| 577 | | - format!("{}{}", icon, entry.name) |
| 578 | | - }; |
| 595 | + }; |
| 579 | 596 | |
| 580 | | - let name_rect = |
| 581 | | - Rect::new(row_rect.x + 8, row_rect.y, self.column_widths[0], ROW_HEIGHT); |
| 582 | | - renderer.text_in_rect(&display_name, name_rect, &name_style)?; |
| 597 | + renderer.text_in_rect(&display_name, name_rect, &name_style)?; |
| 598 | + } |
| 583 | 599 | |
| 584 | 600 | // Size |
| 585 | 601 | let size_rect = Rect::new( |
@@ -603,6 +619,89 @@ impl ListView { |
| 603 | 619 | Ok(()) |
| 604 | 620 | } |
| 605 | 621 | |
| 622 | + /// Render the inline rename text field. |
| 623 | + fn render_rename_field(&self, renderer: &Renderer, rect: Rect, state: &RenameState, icon: &str) -> anyhow::Result<()> { |
| 624 | + let theme = renderer.theme(); |
| 625 | + |
| 626 | + // Background for text field (slightly lighter) |
| 627 | + let field_rect = Rect::new( |
| 628 | + rect.x + 24, // After icon |
| 629 | + rect.y + 2, |
| 630 | + rect.width.saturating_sub(28), |
| 631 | + rect.height - 4, |
| 632 | + ); |
| 633 | + renderer.fill_rounded_rect(field_rect, 2.0, theme.background)?; |
| 634 | + renderer.stroke_rounded_rect(field_rect, 2.0, theme.selection_background, 1.0)?; |
| 635 | + |
| 636 | + // Draw icon |
| 637 | + let icon_style = TextStyle::new() |
| 638 | + .font_family(&theme.font_family) |
| 639 | + .font_size(theme.font_size) |
| 640 | + .color(theme.item_foreground); |
| 641 | + renderer.text(icon, (rect.x + 4) as f64, (rect.y + 4) as f64, &icon_style)?; |
| 642 | + |
| 643 | + // Text style for the editable text |
| 644 | + let text_style = TextStyle::new() |
| 645 | + .font_family(&theme.font_family) |
| 646 | + .font_size(theme.font_size) |
| 647 | + .color(theme.foreground); |
| 648 | + |
| 649 | + // Draw the text |
| 650 | + let text_x = field_rect.x + 4; |
| 651 | + let text_y = field_rect.y + 3; |
| 652 | + renderer.text(&state.text, text_x as f64, text_y as f64, &text_style)?; |
| 653 | + |
| 654 | + // Draw cursor |
| 655 | + let cursor_x = if state.cursor == 0 { |
| 656 | + text_x as f64 |
| 657 | + } else { |
| 658 | + let prefix = &state.text[..state.cursor]; |
| 659 | + let prefix_width = renderer.measure_text(prefix, &text_style)?.width; |
| 660 | + text_x as f64 + prefix_width as f64 |
| 661 | + }; |
| 662 | + renderer.line( |
| 663 | + cursor_x, |
| 664 | + (field_rect.y + 2) as f64, |
| 665 | + cursor_x, |
| 666 | + (field_rect.y + field_rect.height as i32 - 2) as f64, |
| 667 | + theme.foreground, |
| 668 | + 1.0, |
| 669 | + )?; |
| 670 | + |
| 671 | + // Draw selection highlight if any |
| 672 | + if let Some(sel_start) = state.selection_start { |
| 673 | + let (from, to) = if sel_start < state.cursor { |
| 674 | + (sel_start, state.cursor) |
| 675 | + } else { |
| 676 | + (state.cursor, sel_start) |
| 677 | + }; |
| 678 | + |
| 679 | + let from_x = if from == 0 { |
| 680 | + text_x as f64 |
| 681 | + } else { |
| 682 | + let prefix = &state.text[..from]; |
| 683 | + text_x as f64 + renderer.measure_text(prefix, &text_style)?.width as f64 |
| 684 | + }; |
| 685 | + |
| 686 | + let to_x = if to == 0 { |
| 687 | + text_x as f64 |
| 688 | + } else { |
| 689 | + let prefix = &state.text[..to]; |
| 690 | + text_x as f64 + renderer.measure_text(prefix, &text_style)?.width as f64 |
| 691 | + }; |
| 692 | + |
| 693 | + let sel_rect = Rect::new( |
| 694 | + from_x as i32, |
| 695 | + field_rect.y + 2, |
| 696 | + (to_x - from_x) as u32, |
| 697 | + field_rect.height - 4, |
| 698 | + ); |
| 699 | + renderer.fill_rect(sel_rect, theme.selection_background.with_alpha(0.3))?; |
| 700 | + } |
| 701 | + |
| 702 | + Ok(()) |
| 703 | + } |
| 704 | + |
| 606 | 705 | /// Render column headers. |
| 607 | 706 | fn render_header(&self, renderer: &Renderer) -> anyhow::Result<()> { |
| 608 | 707 | let theme = renderer.theme(); |