| 1 | //! List view component for displaying directory contents. |
| 2 | |
| 3 | use crate::core::{EntryType, FileEntry, SortDirection, SortOrder}; |
| 4 | use crate::ui::tab::RenameState; |
| 5 | use gartk_core::{Color, Modifiers, Point, Rect}; |
| 6 | use gartk_render::{Renderer, TextStyle}; |
| 7 | use std::collections::HashSet; |
| 8 | |
| 9 | /// Height of each row in the list view. |
| 10 | pub const ROW_HEIGHT: u32 = 28; |
| 11 | |
| 12 | /// Height of the header row. |
| 13 | pub const HEADER_HEIGHT: u32 = 28; |
| 14 | |
| 15 | /// Minimum column width. |
| 16 | const MIN_COLUMN_WIDTH: u32 = 60; |
| 17 | |
| 18 | /// Column identifier. |
| 19 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 20 | pub enum Column { |
| 21 | Name, |
| 22 | Size, |
| 23 | Modified, |
| 24 | } |
| 25 | |
| 26 | impl Column { |
| 27 | /// Convert to SortOrder. |
| 28 | fn to_sort_order(self) -> SortOrder { |
| 29 | match self { |
| 30 | Column::Name => SortOrder::Name, |
| 31 | Column::Size => SortOrder::Size, |
| 32 | Column::Modified => SortOrder::Modified, |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | /// List view for displaying file entries. |
| 38 | pub struct ListView { |
| 39 | /// Entries to display. |
| 40 | entries: Vec<FileEntry>, |
| 41 | /// Currently focused index (for keyboard nav). |
| 42 | focused: usize, |
| 43 | /// Selected indices (for multi-select). |
| 44 | selected: HashSet<usize>, |
| 45 | /// Anchor index for shift-selection. |
| 46 | selection_anchor: Option<usize>, |
| 47 | /// Scroll offset (first visible row). |
| 48 | scroll_offset: usize, |
| 49 | /// View bounds. |
| 50 | bounds: Rect, |
| 51 | /// Show hidden files. |
| 52 | show_hidden: bool, |
| 53 | /// Current sort order. |
| 54 | sort_order: SortOrder, |
| 55 | /// Current sort direction. |
| 56 | sort_direction: SortDirection, |
| 57 | /// Column widths (name, size, modified). |
| 58 | column_widths: [u32; 3], |
| 59 | /// Column being resized (if any). |
| 60 | resizing_column: Option<usize>, |
| 61 | /// Hovered header column (if any). |
| 62 | hovered_header: Option<Column>, |
| 63 | } |
| 64 | |
| 65 | impl ListView { |
| 66 | /// Create a new list view. |
| 67 | pub fn new(bounds: Rect) -> Self { |
| 68 | // Initial column widths: 50% for name, 100px size, rest for date |
| 69 | let name_width = (bounds.width as f64 * 0.5) as u32; |
| 70 | let size_width = 100; |
| 71 | let date_width = bounds.width.saturating_sub(name_width + size_width + 32); |
| 72 | |
| 73 | Self { |
| 74 | entries: Vec::new(), |
| 75 | focused: 0, |
| 76 | selected: HashSet::new(), |
| 77 | selection_anchor: None, |
| 78 | scroll_offset: 0, |
| 79 | bounds, |
| 80 | show_hidden: false, |
| 81 | sort_order: SortOrder::Name, |
| 82 | sort_direction: SortDirection::Ascending, |
| 83 | column_widths: [name_width, size_width, date_width], |
| 84 | resizing_column: None, |
| 85 | hovered_header: None, |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /// Set the entries to display. |
| 90 | pub fn set_entries(&mut self, entries: Vec<FileEntry>) { |
| 91 | self.entries = entries; |
| 92 | self.focused = 0; |
| 93 | self.selected.clear(); |
| 94 | self.selected.insert(0); |
| 95 | self.selection_anchor = Some(0); |
| 96 | self.scroll_offset = 0; |
| 97 | } |
| 98 | |
| 99 | /// Get visible entries (respecting hidden filter). |
| 100 | pub fn visible_entries(&self) -> Vec<&FileEntry> { |
| 101 | self.entries |
| 102 | .iter() |
| 103 | .filter(|e| self.show_hidden || !e.hidden) |
| 104 | .collect() |
| 105 | } |
| 106 | |
| 107 | /// Get the currently focused entry. |
| 108 | pub fn selected_entry(&self) -> Option<&FileEntry> { |
| 109 | let visible = self.visible_entries(); |
| 110 | visible.get(self.focused).copied() |
| 111 | } |
| 112 | |
| 113 | /// Get the focused index. |
| 114 | pub fn focused_index(&self) -> usize { |
| 115 | self.focused |
| 116 | } |
| 117 | |
| 118 | /// Set the focused index and select it. |
| 119 | pub fn set_focused(&mut self, index: usize) { |
| 120 | if index < self.entries.len() { |
| 121 | self.focused = index; |
| 122 | self.selected.clear(); |
| 123 | self.selected.insert(index); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /// Get all selected entries. |
| 128 | pub fn selected_entries(&self) -> Vec<&FileEntry> { |
| 129 | let visible = self.visible_entries(); |
| 130 | self.selected |
| 131 | .iter() |
| 132 | .filter_map(|&i| visible.get(i).copied()) |
| 133 | .collect() |
| 134 | } |
| 135 | |
| 136 | /// Get selection count. |
| 137 | pub fn selection_count(&self) -> usize { |
| 138 | self.selected.len() |
| 139 | } |
| 140 | |
| 141 | /// Check if an index is selected. |
| 142 | pub fn is_selected(&self, index: usize) -> bool { |
| 143 | self.selected.contains(&index) |
| 144 | } |
| 145 | |
| 146 | /// Get the number of visible rows that fit in the view. |
| 147 | fn visible_rows(&self) -> usize { |
| 148 | let content_height = self.bounds.height.saturating_sub(HEADER_HEIGHT); |
| 149 | (content_height / ROW_HEIGHT).max(1) as usize |
| 150 | } |
| 151 | |
| 152 | /// Get current sort settings. |
| 153 | pub fn sort_settings(&self) -> (SortOrder, SortDirection) { |
| 154 | (self.sort_order, self.sort_direction) |
| 155 | } |
| 156 | |
| 157 | /// Toggle hidden files visibility. |
| 158 | pub fn toggle_hidden(&mut self) { |
| 159 | self.show_hidden = !self.show_hidden; |
| 160 | let visible_count = self.visible_entries().len(); |
| 161 | if self.focused >= visible_count && visible_count > 0 { |
| 162 | self.focused = visible_count - 1; |
| 163 | } |
| 164 | // Revalidate selection |
| 165 | self.selected.retain(|&i| i < visible_count); |
| 166 | if self.selected.is_empty() && visible_count > 0 { |
| 167 | self.selected.insert(self.focused); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | /// Move selection up. |
| 172 | pub fn select_prev(&mut self) { |
| 173 | if self.focused > 0 { |
| 174 | self.focused -= 1; |
| 175 | self.selected.clear(); |
| 176 | self.selected.insert(self.focused); |
| 177 | self.selection_anchor = Some(self.focused); |
| 178 | if self.focused < self.scroll_offset { |
| 179 | self.scroll_offset = self.focused; |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | /// Move selection down. |
| 185 | pub fn select_next(&mut self) { |
| 186 | let visible_count = self.visible_entries().len(); |
| 187 | if self.focused + 1 < visible_count { |
| 188 | self.focused += 1; |
| 189 | self.selected.clear(); |
| 190 | self.selected.insert(self.focused); |
| 191 | self.selection_anchor = Some(self.focused); |
| 192 | let visible_rows = self.visible_rows(); |
| 193 | if self.focused >= self.scroll_offset + visible_rows { |
| 194 | self.scroll_offset = self.focused - visible_rows + 1; |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /// Jump to first entry. |
| 200 | pub fn select_first(&mut self) { |
| 201 | self.focused = 0; |
| 202 | self.selected.clear(); |
| 203 | self.selected.insert(0); |
| 204 | self.selection_anchor = Some(0); |
| 205 | self.scroll_offset = 0; |
| 206 | } |
| 207 | |
| 208 | /// Jump to last entry. |
| 209 | pub fn select_last(&mut self) { |
| 210 | let visible_count = self.visible_entries().len(); |
| 211 | if visible_count > 0 { |
| 212 | self.focused = visible_count - 1; |
| 213 | self.selected.clear(); |
| 214 | self.selected.insert(self.focused); |
| 215 | self.selection_anchor = Some(self.focused); |
| 216 | let visible_rows = self.visible_rows(); |
| 217 | if self.focused >= visible_rows { |
| 218 | self.scroll_offset = self.focused - visible_rows + 1; |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | /// Page up. |
| 224 | pub fn page_up(&mut self) { |
| 225 | let page_size = self.visible_rows(); |
| 226 | if self.focused >= page_size { |
| 227 | self.focused -= page_size; |
| 228 | } else { |
| 229 | self.focused = 0; |
| 230 | } |
| 231 | self.selected.clear(); |
| 232 | self.selected.insert(self.focused); |
| 233 | self.selection_anchor = Some(self.focused); |
| 234 | if self.focused < self.scroll_offset { |
| 235 | self.scroll_offset = self.focused; |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | /// Page down. |
| 240 | pub fn page_down(&mut self) { |
| 241 | let visible_count = self.visible_entries().len(); |
| 242 | let page_size = self.visible_rows(); |
| 243 | self.focused = (self.focused + page_size).min(visible_count.saturating_sub(1)); |
| 244 | self.selected.clear(); |
| 245 | self.selected.insert(self.focused); |
| 246 | self.selection_anchor = Some(self.focused); |
| 247 | if self.focused >= self.scroll_offset + page_size { |
| 248 | self.scroll_offset = self.focused - page_size + 1; |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | /// Select all entries (Ctrl+A). |
| 253 | pub fn select_all(&mut self) { |
| 254 | let visible_count = self.visible_entries().len(); |
| 255 | self.selected = (0..visible_count).collect(); |
| 256 | } |
| 257 | |
| 258 | /// Update bounds. |
| 259 | pub fn set_bounds(&mut self, bounds: Rect) { |
| 260 | self.bounds = bounds; |
| 261 | // Recalculate column widths proportionally |
| 262 | let total_width = bounds.width.saturating_sub(32); |
| 263 | let old_total: u32 = self.column_widths.iter().sum(); |
| 264 | if old_total > 0 { |
| 265 | for width in &mut self.column_widths { |
| 266 | *width = (*width as f64 / old_total as f64 * total_width as f64) as u32; |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | /// Get header bounds. |
| 272 | fn header_bounds(&self) -> Rect { |
| 273 | Rect::new(self.bounds.x, self.bounds.y, self.bounds.width, HEADER_HEIGHT) |
| 274 | } |
| 275 | |
| 276 | /// Get content bounds (below header). |
| 277 | fn content_bounds(&self) -> Rect { |
| 278 | Rect::new( |
| 279 | self.bounds.x, |
| 280 | self.bounds.y + HEADER_HEIGHT as i32, |
| 281 | self.bounds.width, |
| 282 | self.bounds.height.saturating_sub(HEADER_HEIGHT), |
| 283 | ) |
| 284 | } |
| 285 | |
| 286 | /// Get column header bounds for a specific column. |
| 287 | fn column_header_bounds(&self, col: Column) -> Rect { |
| 288 | let header = self.header_bounds(); |
| 289 | match col { |
| 290 | Column::Name => Rect::new(header.x, header.y, self.column_widths[0] + 8, HEADER_HEIGHT), |
| 291 | Column::Size => Rect::new( |
| 292 | header.x + self.column_widths[0] as i32 + 16, |
| 293 | header.y, |
| 294 | self.column_widths[1], |
| 295 | HEADER_HEIGHT, |
| 296 | ), |
| 297 | Column::Modified => Rect::new( |
| 298 | header.x + self.column_widths[0] as i32 + self.column_widths[1] as i32 + 24, |
| 299 | header.y, |
| 300 | self.column_widths[2], |
| 301 | HEADER_HEIGHT, |
| 302 | ), |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | /// Get the X position of a column divider. |
| 307 | fn divider_x(&self, divider_index: usize) -> i32 { |
| 308 | match divider_index { |
| 309 | 0 => self.bounds.x + self.column_widths[0] as i32 + 12, |
| 310 | 1 => self.bounds.x + self.column_widths[0] as i32 + self.column_widths[1] as i32 + 20, |
| 311 | _ => 0, |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | /// Check if position is near a column divider. Returns divider index (0 or 1) if so. |
| 316 | pub fn divider_at(&self, pos: Point) -> Option<usize> { |
| 317 | let header = self.header_bounds(); |
| 318 | if !header.contains_point(pos) { |
| 319 | return None; |
| 320 | } |
| 321 | |
| 322 | for i in 0..2 { |
| 323 | let divider_x = self.divider_x(i); |
| 324 | if (pos.x - divider_x).abs() < 4 { |
| 325 | return Some(i); |
| 326 | } |
| 327 | } |
| 328 | None |
| 329 | } |
| 330 | |
| 331 | /// Start resizing a column divider. |
| 332 | pub fn start_resize(&mut self, divider_index: usize) { |
| 333 | self.resizing_column = Some(divider_index); |
| 334 | } |
| 335 | |
| 336 | /// Stop resizing. |
| 337 | pub fn stop_resize(&mut self) { |
| 338 | self.resizing_column = None; |
| 339 | } |
| 340 | |
| 341 | /// Check if currently resizing. |
| 342 | pub fn is_resizing(&self) -> bool { |
| 343 | self.resizing_column.is_some() |
| 344 | } |
| 345 | |
| 346 | /// Handle mouse move (for hover and resize). |
| 347 | pub fn on_mouse_move(&mut self, pos: Point) { |
| 348 | // Handle active resize |
| 349 | if let Some(divider_index) = self.resizing_column { |
| 350 | let new_x = pos.x; |
| 351 | match divider_index { |
| 352 | 0 => { |
| 353 | // Resizing between Name and Size columns |
| 354 | let new_name_width = (new_x - self.bounds.x - 8).max(MIN_COLUMN_WIDTH as i32) as u32; |
| 355 | let total = self.column_widths[0] + self.column_widths[1]; |
| 356 | let new_size_width = total.saturating_sub(new_name_width).max(MIN_COLUMN_WIDTH); |
| 357 | let adjusted_name = total.saturating_sub(new_size_width); |
| 358 | if adjusted_name >= MIN_COLUMN_WIDTH { |
| 359 | self.column_widths[0] = adjusted_name; |
| 360 | self.column_widths[1] = new_size_width; |
| 361 | } |
| 362 | } |
| 363 | 1 => { |
| 364 | // Resizing between Size and Modified columns |
| 365 | let divider0_x = self.divider_x(0); |
| 366 | let new_size_width = (new_x - divider0_x - 8).max(MIN_COLUMN_WIDTH as i32) as u32; |
| 367 | let total = self.column_widths[1] + self.column_widths[2]; |
| 368 | let new_date_width = total.saturating_sub(new_size_width).max(MIN_COLUMN_WIDTH); |
| 369 | let adjusted_size = total.saturating_sub(new_date_width); |
| 370 | if adjusted_size >= MIN_COLUMN_WIDTH { |
| 371 | self.column_widths[1] = adjusted_size; |
| 372 | self.column_widths[2] = new_date_width; |
| 373 | } |
| 374 | } |
| 375 | _ => {} |
| 376 | } |
| 377 | return; |
| 378 | } |
| 379 | |
| 380 | if !self.bounds.contains_point(pos) { |
| 381 | self.hovered_header = None; |
| 382 | return; |
| 383 | } |
| 384 | |
| 385 | let header = self.header_bounds(); |
| 386 | if header.contains_point(pos) { |
| 387 | // Check for column resize zones |
| 388 | if self.divider_at(pos).is_some() { |
| 389 | self.hovered_header = None; |
| 390 | return; |
| 391 | } |
| 392 | |
| 393 | // Check column headers for hover |
| 394 | for col in [Column::Name, Column::Size, Column::Modified] { |
| 395 | if self.column_header_bounds(col).contains_point(pos) { |
| 396 | self.hovered_header = Some(col); |
| 397 | return; |
| 398 | } |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | self.hovered_header = None; |
| 403 | } |
| 404 | |
| 405 | /// Handle header click for sorting. Returns (new_order, new_direction) if sort changed. |
| 406 | /// Note: Call divider_at() first to check for resize initiation. |
| 407 | pub fn on_header_click(&mut self, pos: Point) -> Option<(SortOrder, SortDirection)> { |
| 408 | let header = self.header_bounds(); |
| 409 | if !header.contains_point(pos) { |
| 410 | return None; |
| 411 | } |
| 412 | |
| 413 | // Don't sort if clicking on a divider |
| 414 | if self.divider_at(pos).is_some() { |
| 415 | return None; |
| 416 | } |
| 417 | |
| 418 | for col in [Column::Name, Column::Size, Column::Modified] { |
| 419 | if self.column_header_bounds(col).contains_point(pos) { |
| 420 | let new_order = col.to_sort_order(); |
| 421 | if self.sort_order == new_order { |
| 422 | // Toggle direction |
| 423 | self.sort_direction = match self.sort_direction { |
| 424 | SortDirection::Ascending => SortDirection::Descending, |
| 425 | SortDirection::Descending => SortDirection::Ascending, |
| 426 | }; |
| 427 | } else { |
| 428 | self.sort_order = new_order; |
| 429 | self.sort_direction = SortDirection::Ascending; |
| 430 | } |
| 431 | return Some((self.sort_order, self.sort_direction)); |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | None |
| 436 | } |
| 437 | |
| 438 | /// Get the entry at the given position (for drag detection). |
| 439 | pub fn entry_at_point(&self, pos: Point) -> Option<&FileEntry> { |
| 440 | let content = self.content_bounds(); |
| 441 | if !content.contains_point(pos) { |
| 442 | return None; |
| 443 | } |
| 444 | |
| 445 | let relative_y = pos.y - content.y; |
| 446 | let row_index = self.scroll_offset + (relative_y / ROW_HEIGHT as i32) as usize; |
| 447 | |
| 448 | let visible = self.visible_entries(); |
| 449 | visible.get(row_index).copied() |
| 450 | } |
| 451 | |
| 452 | /// Handle row click. Returns index of clicked row if valid. |
| 453 | pub fn on_row_click(&mut self, pos: Point, modifiers: &Modifiers) -> Option<usize> { |
| 454 | let content = self.content_bounds(); |
| 455 | if !content.contains_point(pos) { |
| 456 | return None; |
| 457 | } |
| 458 | |
| 459 | let relative_y = pos.y - content.y; |
| 460 | let row_index = self.scroll_offset + (relative_y / ROW_HEIGHT as i32) as usize; |
| 461 | |
| 462 | let visible_count = self.visible_entries().len(); |
| 463 | if row_index >= visible_count { |
| 464 | return None; |
| 465 | } |
| 466 | |
| 467 | if modifiers.ctrl { |
| 468 | // Ctrl+click: toggle selection |
| 469 | if self.selected.contains(&row_index) { |
| 470 | self.selected.remove(&row_index); |
| 471 | } else { |
| 472 | self.selected.insert(row_index); |
| 473 | } |
| 474 | self.focused = row_index; |
| 475 | self.selection_anchor = Some(row_index); |
| 476 | } else if modifiers.shift { |
| 477 | // Shift+click: range selection |
| 478 | if let Some(anchor) = self.selection_anchor { |
| 479 | let (start, end) = if anchor <= row_index { |
| 480 | (anchor, row_index) |
| 481 | } else { |
| 482 | (row_index, anchor) |
| 483 | }; |
| 484 | self.selected = (start..=end).collect(); |
| 485 | } else { |
| 486 | self.selected.clear(); |
| 487 | self.selected.insert(row_index); |
| 488 | self.selection_anchor = Some(row_index); |
| 489 | } |
| 490 | self.focused = row_index; |
| 491 | } else { |
| 492 | // Plain click: single selection |
| 493 | self.selected.clear(); |
| 494 | self.selected.insert(row_index); |
| 495 | self.focused = row_index; |
| 496 | self.selection_anchor = Some(row_index); |
| 497 | } |
| 498 | |
| 499 | Some(row_index) |
| 500 | } |
| 501 | |
| 502 | /// Clear hover state. |
| 503 | pub fn clear_hover(&mut self) { |
| 504 | self.hovered_header = None; |
| 505 | } |
| 506 | |
| 507 | /// Render the list view. |
| 508 | pub fn render(&self, renderer: &Renderer, rename_state: Option<&RenameState>) -> anyhow::Result<()> { |
| 509 | let theme = renderer.theme(); |
| 510 | let visible = self.visible_entries(); |
| 511 | let visible_rows = self.visible_rows(); |
| 512 | |
| 513 | // Draw header |
| 514 | self.render_header(renderer)?; |
| 515 | |
| 516 | // Draw entries |
| 517 | let content = self.content_bounds(); |
| 518 | for (i, entry) in visible |
| 519 | .iter() |
| 520 | .skip(self.scroll_offset) |
| 521 | .take(visible_rows) |
| 522 | .enumerate() |
| 523 | { |
| 524 | let y = content.y + (i as i32 * ROW_HEIGHT as i32); |
| 525 | let row_rect = Rect::new(content.x, y, content.width, ROW_HEIGHT); |
| 526 | |
| 527 | let actual_index = self.scroll_offset + i; |
| 528 | let is_selected = self.selected.contains(&actual_index); |
| 529 | let is_focused = actual_index == self.focused; |
| 530 | let is_renaming = rename_state.map_or(false, |s| s.index == actual_index); |
| 531 | |
| 532 | // Row background |
| 533 | if is_selected { |
| 534 | renderer.fill_rounded_rect(row_rect, 4.0, theme.item_selected_background)?; |
| 535 | } else if i % 2 == 1 { |
| 536 | renderer.fill_rect(row_rect, theme.item_background.with_alpha(0.3))?; |
| 537 | } |
| 538 | |
| 539 | // Focus indicator (subtle border) |
| 540 | if is_focused && self.selected.len() > 1 { |
| 541 | renderer.stroke_rect(row_rect, theme.selection_background.with_alpha(0.5), 1.0)?; |
| 542 | } |
| 543 | |
| 544 | // Determine colors |
| 545 | let text_color = if is_selected { |
| 546 | theme.selection_foreground |
| 547 | } else if entry.hidden { |
| 548 | theme.item_foreground.with_alpha(0.5) |
| 549 | } else { |
| 550 | theme.item_foreground |
| 551 | }; |
| 552 | |
| 553 | let name_color = match entry.entry_type { |
| 554 | EntryType::Directory => Color::from_hex("#5c9fd8").unwrap_or(text_color), |
| 555 | EntryType::Symlink => Color::from_hex("#c678dd").unwrap_or(text_color), |
| 556 | _ => text_color, |
| 557 | }; |
| 558 | |
| 559 | let text_style = TextStyle::new() |
| 560 | .font_family(&theme.font_family) |
| 561 | .font_size(theme.font_size) |
| 562 | .color(text_color); |
| 563 | |
| 564 | let name_style = TextStyle::new() |
| 565 | .font_family(&theme.font_family) |
| 566 | .font_size(theme.font_size) |
| 567 | .color(if is_selected { |
| 568 | theme.selection_foreground |
| 569 | } else { |
| 570 | name_color |
| 571 | }); |
| 572 | |
| 573 | // Name with icon prefix |
| 574 | let icon = match entry.entry_type { |
| 575 | EntryType::Directory => "\u{1F4C1} ", |
| 576 | EntryType::Symlink => "\u{1F517} ", |
| 577 | _ => "\u{1F4C4} ", |
| 578 | }; |
| 579 | |
| 580 | let name_rect = |
| 581 | Rect::new(row_rect.x + 8, row_rect.y, self.column_widths[0], ROW_HEIGHT); |
| 582 | |
| 583 | if is_renaming { |
| 584 | // Render rename text field |
| 585 | if let Some(state) = rename_state { |
| 586 | self.render_rename_field(renderer, name_rect, state, &icon)?; |
| 587 | } |
| 588 | } else { |
| 589 | let display_name = if entry.is_symlink { |
| 590 | if let Some(target) = &entry.symlink_target { |
| 591 | let target_str = target.to_string_lossy(); |
| 592 | // Truncate long targets |
| 593 | let target_display = if target_str.len() > 30 { |
| 594 | format!("...{}", &target_str[target_str.len()-27..]) |
| 595 | } else { |
| 596 | target_str.to_string() |
| 597 | }; |
| 598 | format!("{}{} -> {}", icon, entry.name, target_display) |
| 599 | } else { |
| 600 | format!("{}{}", icon, entry.name) |
| 601 | } |
| 602 | } else { |
| 603 | format!("{}{}", icon, entry.name) |
| 604 | }; |
| 605 | |
| 606 | renderer.text_in_rect(&display_name, name_rect, &name_style)?; |
| 607 | } |
| 608 | |
| 609 | // Size |
| 610 | let size_rect = Rect::new( |
| 611 | row_rect.x + self.column_widths[0] as i32 + 16, |
| 612 | row_rect.y, |
| 613 | self.column_widths[1], |
| 614 | ROW_HEIGHT, |
| 615 | ); |
| 616 | renderer.text_in_rect(&entry.format_size(), size_rect, &text_style)?; |
| 617 | |
| 618 | // Modified date |
| 619 | let date_rect = Rect::new( |
| 620 | row_rect.x + self.column_widths[0] as i32 + self.column_widths[1] as i32 + 24, |
| 621 | row_rect.y, |
| 622 | self.column_widths[2], |
| 623 | ROW_HEIGHT, |
| 624 | ); |
| 625 | renderer.text_in_rect(&entry.format_modified(), date_rect, &text_style)?; |
| 626 | } |
| 627 | |
| 628 | Ok(()) |
| 629 | } |
| 630 | |
| 631 | /// Render the inline rename text field. |
| 632 | fn render_rename_field(&self, renderer: &Renderer, rect: Rect, state: &RenameState, icon: &str) -> anyhow::Result<()> { |
| 633 | let theme = renderer.theme(); |
| 634 | |
| 635 | // Background for text field (slightly lighter) |
| 636 | let field_rect = Rect::new( |
| 637 | rect.x + 24, // After icon |
| 638 | rect.y + 2, |
| 639 | rect.width.saturating_sub(28), |
| 640 | rect.height - 4, |
| 641 | ); |
| 642 | renderer.fill_rounded_rect(field_rect, 2.0, theme.background)?; |
| 643 | renderer.stroke_rounded_rect(field_rect, 2.0, theme.selection_background, 1.0)?; |
| 644 | |
| 645 | // Draw icon |
| 646 | let icon_style = TextStyle::new() |
| 647 | .font_family(&theme.font_family) |
| 648 | .font_size(theme.font_size) |
| 649 | .color(theme.item_foreground); |
| 650 | renderer.text(icon, (rect.x + 4) as f64, (rect.y + 4) as f64, &icon_style)?; |
| 651 | |
| 652 | // Text style for the editable text |
| 653 | let text_style = TextStyle::new() |
| 654 | .font_family(&theme.font_family) |
| 655 | .font_size(theme.font_size) |
| 656 | .color(theme.foreground); |
| 657 | |
| 658 | // Draw the text |
| 659 | let text_x = field_rect.x + 4; |
| 660 | let text_y = field_rect.y + 3; |
| 661 | renderer.text(&state.text, text_x as f64, text_y as f64, &text_style)?; |
| 662 | |
| 663 | // Draw cursor |
| 664 | let cursor_x = if state.cursor == 0 { |
| 665 | text_x as f64 |
| 666 | } else { |
| 667 | let prefix = &state.text[..state.cursor]; |
| 668 | let prefix_width = renderer.measure_text(prefix, &text_style)?.width; |
| 669 | text_x as f64 + prefix_width as f64 |
| 670 | }; |
| 671 | renderer.line( |
| 672 | cursor_x, |
| 673 | (field_rect.y + 2) as f64, |
| 674 | cursor_x, |
| 675 | (field_rect.y + field_rect.height as i32 - 2) as f64, |
| 676 | theme.foreground, |
| 677 | 1.0, |
| 678 | )?; |
| 679 | |
| 680 | // Draw selection highlight if any |
| 681 | if let Some(sel_start) = state.selection_start { |
| 682 | let (from, to) = if sel_start < state.cursor { |
| 683 | (sel_start, state.cursor) |
| 684 | } else { |
| 685 | (state.cursor, sel_start) |
| 686 | }; |
| 687 | |
| 688 | let from_x = if from == 0 { |
| 689 | text_x as f64 |
| 690 | } else { |
| 691 | let prefix = &state.text[..from]; |
| 692 | text_x as f64 + renderer.measure_text(prefix, &text_style)?.width as f64 |
| 693 | }; |
| 694 | |
| 695 | let to_x = if to == 0 { |
| 696 | text_x as f64 |
| 697 | } else { |
| 698 | let prefix = &state.text[..to]; |
| 699 | text_x as f64 + renderer.measure_text(prefix, &text_style)?.width as f64 |
| 700 | }; |
| 701 | |
| 702 | let sel_rect = Rect::new( |
| 703 | from_x as i32, |
| 704 | field_rect.y + 2, |
| 705 | (to_x - from_x) as u32, |
| 706 | field_rect.height - 4, |
| 707 | ); |
| 708 | renderer.fill_rect(sel_rect, theme.selection_background.with_alpha(0.3))?; |
| 709 | } |
| 710 | |
| 711 | Ok(()) |
| 712 | } |
| 713 | |
| 714 | /// Render column headers. |
| 715 | fn render_header(&self, renderer: &Renderer) -> anyhow::Result<()> { |
| 716 | let theme = renderer.theme(); |
| 717 | let header = self.header_bounds(); |
| 718 | |
| 719 | renderer.fill_rect(header, theme.item_background.darken(0.1))?; |
| 720 | |
| 721 | let header_style = TextStyle::new() |
| 722 | .font_family(&theme.font_family) |
| 723 | .font_size(theme.font_size) |
| 724 | .color(theme.item_foreground.with_alpha(0.7)); |
| 725 | |
| 726 | let hover_style = TextStyle::new() |
| 727 | .font_family(&theme.font_family) |
| 728 | .font_size(theme.font_size) |
| 729 | .color(theme.selection_background); |
| 730 | |
| 731 | // Sort indicator |
| 732 | let sort_indicator = match self.sort_direction { |
| 733 | SortDirection::Ascending => " \u{25B2}", // ▲ |
| 734 | SortDirection::Descending => " \u{25BC}", // ▼ |
| 735 | }; |
| 736 | |
| 737 | // Name header |
| 738 | let name_bounds = self.column_header_bounds(Column::Name); |
| 739 | let name_style = if self.hovered_header == Some(Column::Name) { |
| 740 | &hover_style |
| 741 | } else { |
| 742 | &header_style |
| 743 | }; |
| 744 | let name_label = if self.sort_order == SortOrder::Name { |
| 745 | format!("Name{}", sort_indicator) |
| 746 | } else { |
| 747 | "Name".to_string() |
| 748 | }; |
| 749 | renderer.text_in_rect(&name_label, name_bounds, name_style)?; |
| 750 | |
| 751 | // Size header |
| 752 | let size_bounds = self.column_header_bounds(Column::Size); |
| 753 | let size_style = if self.hovered_header == Some(Column::Size) { |
| 754 | &hover_style |
| 755 | } else { |
| 756 | &header_style |
| 757 | }; |
| 758 | let size_label = if self.sort_order == SortOrder::Size { |
| 759 | format!("Size{}", sort_indicator) |
| 760 | } else { |
| 761 | "Size".to_string() |
| 762 | }; |
| 763 | renderer.text_in_rect(&size_label, size_bounds, size_style)?; |
| 764 | |
| 765 | // Modified header |
| 766 | let modified_bounds = self.column_header_bounds(Column::Modified); |
| 767 | let modified_style = if self.hovered_header == Some(Column::Modified) { |
| 768 | &hover_style |
| 769 | } else { |
| 770 | &header_style |
| 771 | }; |
| 772 | let modified_label = if self.sort_order == SortOrder::Modified { |
| 773 | format!("Modified{}", sort_indicator) |
| 774 | } else { |
| 775 | "Modified".to_string() |
| 776 | }; |
| 777 | renderer.text_in_rect(&modified_label, modified_bounds, modified_style)?; |
| 778 | |
| 779 | // Draw column dividers |
| 780 | let divider_color = theme.border.with_alpha(0.3); |
| 781 | let divider1_x = (header.x + self.column_widths[0] as i32 + 12) as f64; |
| 782 | renderer.line( |
| 783 | divider1_x, |
| 784 | (header.y + 4) as f64, |
| 785 | divider1_x, |
| 786 | (header.y + header.height as i32 - 4) as f64, |
| 787 | divider_color, |
| 788 | 1.0, |
| 789 | )?; |
| 790 | |
| 791 | let divider2_x = divider1_x + self.column_widths[1] as f64 + 8.0; |
| 792 | renderer.line( |
| 793 | divider2_x, |
| 794 | (header.y + 4) as f64, |
| 795 | divider2_x, |
| 796 | (header.y + header.height as i32 - 4) as f64, |
| 797 | divider_color, |
| 798 | 1.0, |
| 799 | )?; |
| 800 | |
| 801 | Ok(()) |
| 802 | } |
| 803 | } |
| 804 |