@@ -5,6 +5,7 @@ use crate::ui::tab::RenameState; |
| 5 | use gartk_core::{Color, Modifiers, Point, Rect}; | 5 | use gartk_core::{Color, Modifiers, Point, Rect}; |
| 6 | use gartk_render::{Renderer, TextAlign, TextStyle}; | 6 | use gartk_render::{Renderer, TextAlign, TextStyle}; |
| 7 | use std::collections::HashSet; | 7 | use std::collections::HashSet; |
| | 8 | +use std::time::{Duration, Instant}; |
| 8 | | 9 | |
| 9 | /// Padding around cells. | 10 | /// Padding around cells. |
| 10 | pub const CELL_PADDING: u32 = 8; | 11 | pub const CELL_PADDING: u32 = 8; |
@@ -69,6 +70,8 @@ impl IconSize { |
| 69 | pub struct GridView { | 70 | pub struct GridView { |
| 70 | /// Entries to display. | 71 | /// Entries to display. |
| 71 | entries: Vec<FileEntry>, | 72 | entries: Vec<FileEntry>, |
| | 73 | + /// Cached indices of visible entries (respecting hidden filter). |
| | 74 | + visible_indices: Vec<usize>, |
| 72 | /// Currently focused index (for keyboard nav). | 75 | /// Currently focused index (for keyboard nav). |
| 73 | focused: usize, | 76 | focused: usize, |
| 74 | /// Selected indices (for multi-select). | 77 | /// Selected indices (for multi-select). |
@@ -91,6 +94,10 @@ pub struct GridView { |
| 91 | drag_current: Option<Point>, | 94 | drag_current: Option<Point>, |
| 92 | /// Icon size setting. | 95 | /// Icon size setting. |
| 93 | icon_size: IconSize, | 96 | icon_size: IconSize, |
| | 97 | + /// Last time rubber band selection was updated (for throttling). |
| | 98 | + last_selection_update: Option<Instant>, |
| | 99 | + /// Last time we requested a redraw during drag (for frame rate limiting). |
| | 100 | + last_drag_render: Option<Instant>, |
| 94 | } | 101 | } |
| 95 | | 102 | |
| 96 | impl GridView { | 103 | impl GridView { |
@@ -100,6 +107,7 @@ impl GridView { |
| 100 | let columns = Self::calculate_columns_for_size(bounds.width, icon_size); | 107 | let columns = Self::calculate_columns_for_size(bounds.width, icon_size); |
| 101 | Self { | 108 | Self { |
| 102 | entries: Vec::new(), | 109 | entries: Vec::new(), |
| | 110 | + visible_indices: Vec::new(), |
| 103 | focused: 0, | 111 | focused: 0, |
| 104 | selected: HashSet::new(), | 112 | selected: HashSet::new(), |
| 105 | selection_anchor: None, | 113 | selection_anchor: None, |
@@ -111,9 +119,21 @@ impl GridView { |
| 111 | drag_start: None, | 119 | drag_start: None, |
| 112 | drag_current: None, | 120 | drag_current: None, |
| 113 | icon_size, | 121 | icon_size, |
| | 122 | + last_selection_update: None, |
| | 123 | + last_drag_render: None, |
| 114 | } | 124 | } |
| 115 | } | 125 | } |
| 116 | | 126 | |
| | 127 | + /// Rebuild the visible indices cache. |
| | 128 | + fn rebuild_visible_cache(&mut self) { |
| | 129 | + self.visible_indices = self.entries |
| | 130 | + .iter() |
| | 131 | + .enumerate() |
| | 132 | + .filter(|(_, e)| self.show_hidden || !e.hidden) |
| | 133 | + .map(|(i, _)| i) |
| | 134 | + .collect(); |
| | 135 | + } |
| | 136 | + |
| 117 | /// Calculate number of columns that fit in the given width for a specific icon size. | 137 | /// Calculate number of columns that fit in the given width for a specific icon size. |
| 118 | fn calculate_columns_for_size(width: u32, icon_size: IconSize) -> usize { | 138 | fn calculate_columns_for_size(width: u32, icon_size: IconSize) -> usize { |
| 119 | let cell_size = icon_size.cell_size(); | 139 | let cell_size = icon_size.cell_size(); |
@@ -144,6 +164,7 @@ impl GridView { |
| 144 | /// Set the entries to display. | 164 | /// Set the entries to display. |
| 145 | pub fn set_entries(&mut self, entries: Vec<FileEntry>) { | 165 | pub fn set_entries(&mut self, entries: Vec<FileEntry>) { |
| 146 | self.entries = entries; | 166 | self.entries = entries; |
| | 167 | + self.rebuild_visible_cache(); |
| 147 | self.focused = 0; | 168 | self.focused = 0; |
| 148 | self.selected.clear(); | 169 | self.selected.clear(); |
| 149 | self.selected.insert(0); | 170 | self.selected.insert(0); |
@@ -152,17 +173,29 @@ impl GridView { |
| 152 | } | 173 | } |
| 153 | | 174 | |
| 154 | /// Get visible entries (respecting hidden filter). | 175 | /// Get visible entries (respecting hidden filter). |
| | 176 | + /// Uses cached indices for efficiency. |
| 155 | pub fn visible_entries(&self) -> Vec<&FileEntry> { | 177 | pub fn visible_entries(&self) -> Vec<&FileEntry> { |
| 156 | - self.entries | 178 | + self.visible_indices |
| 157 | .iter() | 179 | .iter() |
| 158 | - .filter(|e| self.show_hidden || !e.hidden) | 180 | + .filter_map(|&i| self.entries.get(i)) |
| 159 | .collect() | 181 | .collect() |
| 160 | } | 182 | } |
| 161 | | 183 | |
| | 184 | + /// Get visible entry count without allocation. |
| | 185 | + #[inline] |
| | 186 | + pub fn visible_count(&self) -> usize { |
| | 187 | + self.visible_indices.len() |
| | 188 | + } |
| | 189 | + |
| | 190 | + /// Get entry at visible index without allocation. |
| | 191 | + #[inline] |
| | 192 | + pub fn visible_entry(&self, visible_idx: usize) -> Option<&FileEntry> { |
| | 193 | + self.visible_indices.get(visible_idx).and_then(|&i| self.entries.get(i)) |
| | 194 | + } |
| | 195 | + |
| 162 | /// Get the currently focused entry. | 196 | /// Get the currently focused entry. |
| 163 | pub fn selected_entry(&self) -> Option<&FileEntry> { | 197 | pub fn selected_entry(&self) -> Option<&FileEntry> { |
| 164 | - let visible = self.visible_entries(); | 198 | + self.visible_entry(self.focused) |
| 165 | - visible.get(self.focused).copied() | | |
| 166 | } | 199 | } |
| 167 | | 200 | |
| 168 | /// Get the focused index. | 201 | /// Get the focused index. |
@@ -181,10 +214,9 @@ impl GridView { |
| 181 | | 214 | |
| 182 | /// Get all selected entries. | 215 | /// Get all selected entries. |
| 183 | pub fn selected_entries(&self) -> Vec<&FileEntry> { | 216 | pub fn selected_entries(&self) -> Vec<&FileEntry> { |
| 184 | - let visible = self.visible_entries(); | | |
| 185 | self.selected | 217 | self.selected |
| 186 | .iter() | 218 | .iter() |
| 187 | - .filter_map(|&i| visible.get(i).copied()) | 219 | + .filter_map(|&i| self.visible_entry(i)) |
| 188 | .collect() | 220 | .collect() |
| 189 | } | 221 | } |
| 190 | | 222 | |
@@ -207,7 +239,8 @@ impl GridView { |
| 207 | /// Toggle hidden files visibility. | 239 | /// Toggle hidden files visibility. |
| 208 | pub fn toggle_hidden(&mut self) { | 240 | pub fn toggle_hidden(&mut self) { |
| 209 | self.show_hidden = !self.show_hidden; | 241 | self.show_hidden = !self.show_hidden; |
| 210 | - let visible_count = self.visible_entries().len(); | 242 | + self.rebuild_visible_cache(); |
| | 243 | + let visible_count = self.visible_count(); |
| 211 | if self.focused >= visible_count && visible_count > 0 { | 244 | if self.focused >= visible_count && visible_count > 0 { |
| 212 | self.focused = visible_count - 1; | 245 | self.focused = visible_count - 1; |
| 213 | } | 246 | } |
@@ -229,7 +262,7 @@ impl GridView { |
| 229 | | 262 | |
| 230 | /// Move selection down (to next row). | 263 | /// Move selection down (to next row). |
| 231 | pub fn select_next(&mut self) { | 264 | pub fn select_next(&mut self) { |
| 232 | - let visible_count = self.visible_entries().len(); | 265 | + let visible_count = self.visible_count(); |
| 233 | if self.focused + self.columns < visible_count { | 266 | if self.focused + self.columns < visible_count { |
| 234 | self.focused += self.columns; | 267 | self.focused += self.columns; |
| 235 | } else if self.focused < visible_count.saturating_sub(1) { | 268 | } else if self.focused < visible_count.saturating_sub(1) { |
@@ -248,7 +281,7 @@ impl GridView { |
| 248 | | 281 | |
| 249 | /// Move selection right. | 282 | /// Move selection right. |
| 250 | pub fn select_right(&mut self) { | 283 | pub fn select_right(&mut self) { |
| 251 | - let visible_count = self.visible_entries().len(); | 284 | + let visible_count = self.visible_count(); |
| 252 | if self.focused + 1 < visible_count { | 285 | if self.focused + 1 < visible_count { |
| 253 | self.focused += 1; | 286 | self.focused += 1; |
| 254 | self.update_single_selection(); | 287 | self.update_single_selection(); |
@@ -283,7 +316,7 @@ impl GridView { |
| 283 | | 316 | |
| 284 | /// Jump to last entry. | 317 | /// Jump to last entry. |
| 285 | pub fn select_last(&mut self) { | 318 | pub fn select_last(&mut self) { |
| 286 | - let visible_count = self.visible_entries().len(); | 319 | + let visible_count = self.visible_count(); |
| 287 | if visible_count > 0 { | 320 | if visible_count > 0 { |
| 288 | self.focused = visible_count - 1; | 321 | self.focused = visible_count - 1; |
| 289 | self.update_single_selection(); | 322 | self.update_single_selection(); |
@@ -303,7 +336,7 @@ impl GridView { |
| 303 | | 336 | |
| 304 | /// Page down. | 337 | /// Page down. |
| 305 | pub fn page_down(&mut self) { | 338 | pub fn page_down(&mut self) { |
| 306 | - let visible_count = self.visible_entries().len(); | 339 | + let visible_count = self.visible_count(); |
| 307 | let page_size = self.visible_rows() * self.columns; | 340 | let page_size = self.visible_rows() * self.columns; |
| 308 | self.focused = (self.focused + page_size).min(visible_count.saturating_sub(1)); | 341 | self.focused = (self.focused + page_size).min(visible_count.saturating_sub(1)); |
| 309 | self.update_single_selection(); | 342 | self.update_single_selection(); |
@@ -311,7 +344,7 @@ impl GridView { |
| 311 | | 344 | |
| 312 | /// Select all entries (Ctrl+A). | 345 | /// Select all entries (Ctrl+A). |
| 313 | pub fn select_all(&mut self) { | 346 | pub fn select_all(&mut self) { |
| 314 | - let visible_count = self.visible_entries().len(); | 347 | + let visible_count = self.visible_count(); |
| 315 | self.selected = (0..visible_count).collect(); | 348 | self.selected = (0..visible_count).collect(); |
| 316 | } | 349 | } |
| 317 | | 350 | |
@@ -344,8 +377,32 @@ impl GridView { |
| 344 | // Handle rubber band drag | 377 | // Handle rubber band drag |
| 345 | if self.drag_start.is_some() { | 378 | if self.drag_start.is_some() { |
| 346 | self.drag_current = Some(pos); | 379 | self.drag_current = Some(pos); |
| 347 | - self.update_rubber_band_selection(); | 380 | + |
| 348 | - return true; // Rubber band always needs redraw | 381 | + // Throttle both selection updates AND render requests to ~60fps |
| | 382 | + let should_update = match self.last_selection_update { |
| | 383 | + Some(last) => last.elapsed() >= Duration::from_millis(16), |
| | 384 | + None => true, |
| | 385 | + }; |
| | 386 | + |
| | 387 | + if should_update { |
| | 388 | + self.update_rubber_band_selection(); |
| | 389 | + self.last_selection_update = Some(Instant::now()); |
| | 390 | + self.last_drag_render = Some(Instant::now()); |
| | 391 | + return true; // Request redraw at throttled rate |
| | 392 | + } |
| | 393 | + |
| | 394 | + // Also limit render requests independently (in case selection didn't change) |
| | 395 | + let should_render = match self.last_drag_render { |
| | 396 | + Some(last) => last.elapsed() >= Duration::from_millis(16), |
| | 397 | + None => true, |
| | 398 | + }; |
| | 399 | + |
| | 400 | + if should_render { |
| | 401 | + self.last_drag_render = Some(Instant::now()); |
| | 402 | + return true; |
| | 403 | + } |
| | 404 | + |
| | 405 | + return false; // Skip redraw, we're within throttle window |
| 349 | } | 406 | } |
| 350 | | 407 | |
| 351 | let old_hovered = self.hovered; | 408 | let old_hovered = self.hovered; |
@@ -355,9 +412,9 @@ impl GridView { |
| 355 | return self.hovered != old_hovered; | 412 | return self.hovered != old_hovered; |
| 356 | } | 413 | } |
| 357 | | 414 | |
| 358 | - let visible = self.visible_entries(); | 415 | + let visible_count = self.visible_count(); |
| 359 | let start_index = self.scroll_offset * self.columns; | 416 | let start_index = self.scroll_offset * self.columns; |
| 360 | - let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len()); | 417 | + let end_index = (start_index + self.visible_rows() * self.columns).min(visible_count); |
| 361 | | 418 | |
| 362 | self.hovered = None; | 419 | self.hovered = None; |
| 363 | for i in start_index..end_index { | 420 | for i in start_index..end_index { |
@@ -375,6 +432,8 @@ impl GridView { |
| 375 | self.drag_start = Some(pos); | 432 | self.drag_start = Some(pos); |
| 376 | self.drag_current = Some(pos); | 433 | self.drag_current = Some(pos); |
| 377 | self.selected.clear(); | 434 | self.selected.clear(); |
| | 435 | + self.last_selection_update = None; |
| | 436 | + self.last_drag_render = None; |
| 378 | } | 437 | } |
| 379 | | 438 | |
| 380 | /// Check if rubber band drag is active. | 439 | /// Check if rubber band drag is active. |
@@ -384,8 +443,12 @@ impl GridView { |
| 384 | | 443 | |
| 385 | /// Stop rubber band selection. | 444 | /// Stop rubber band selection. |
| 386 | pub fn stop_drag(&mut self) { | 445 | pub fn stop_drag(&mut self) { |
| | 446 | + // Final selection update before clearing drag state |
| | 447 | + self.update_rubber_band_selection(); |
| 387 | self.drag_start = None; | 448 | self.drag_start = None; |
| 388 | self.drag_current = None; | 449 | self.drag_current = None; |
| | 450 | + self.last_selection_update = None; |
| | 451 | + self.last_drag_render = None; |
| 389 | } | 452 | } |
| 390 | | 453 | |
| 391 | /// Get the rubber band rectangle (if dragging). | 454 | /// Get the rubber band rectangle (if dragging). |
@@ -404,9 +467,9 @@ impl GridView { |
| 404 | /// Update selection based on rubber band rectangle. | 467 | /// Update selection based on rubber band rectangle. |
| 405 | fn update_rubber_band_selection(&mut self) { | 468 | fn update_rubber_band_selection(&mut self) { |
| 406 | if let Some(band) = self.rubber_band_rect() { | 469 | if let Some(band) = self.rubber_band_rect() { |
| 407 | - let visible = self.visible_entries(); | 470 | + let visible_count = self.visible_count(); |
| 408 | let start_index = self.scroll_offset * self.columns; | 471 | let start_index = self.scroll_offset * self.columns; |
| 409 | - let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len()); | 472 | + let end_index = (start_index + self.visible_rows() * self.columns).min(visible_count); |
| 410 | | 473 | |
| 411 | self.selected.clear(); | 474 | self.selected.clear(); |
| 412 | for i in start_index..end_index { | 475 | for i in start_index..end_index { |
@@ -424,13 +487,13 @@ impl GridView { |
| 424 | return None; | 487 | return None; |
| 425 | } | 488 | } |
| 426 | | 489 | |
| 427 | - let visible = self.visible_entries(); | 490 | + let visible_count = self.visible_count(); |
| 428 | let start_index = self.scroll_offset * self.columns; | 491 | let start_index = self.scroll_offset * self.columns; |
| 429 | - let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len()); | 492 | + let end_index = (start_index + self.visible_rows() * self.columns).min(visible_count); |
| 430 | | 493 | |
| 431 | for i in start_index..end_index { | 494 | for i in start_index..end_index { |
| 432 | if self.cell_bounds(i).contains_point(pos) { | 495 | if self.cell_bounds(i).contains_point(pos) { |
| 433 | - return visible.get(i).copied(); | 496 | + return self.visible_entry(i); |
| 434 | } | 497 | } |
| 435 | } | 498 | } |
| 436 | | 499 | |
@@ -443,9 +506,9 @@ impl GridView { |
| 443 | return None; | 506 | return None; |
| 444 | } | 507 | } |
| 445 | | 508 | |
| 446 | - let visible = self.visible_entries(); | 509 | + let visible_count = self.visible_count(); |
| 447 | let start_index = self.scroll_offset * self.columns; | 510 | let start_index = self.scroll_offset * self.columns; |
| 448 | - let end_index = (start_index + self.visible_rows() * self.columns).min(visible.len()); | 511 | + let end_index = (start_index + self.visible_rows() * self.columns).min(visible_count); |
| 449 | | 512 | |
| 450 | for i in start_index..end_index { | 513 | for i in start_index..end_index { |
| 451 | if self.cell_bounds(i).contains_point(pos) { | 514 | if self.cell_bounds(i).contains_point(pos) { |
@@ -499,14 +562,30 @@ impl GridView { |
| 499 | /// Render the grid view. | 562 | /// Render the grid view. |
| 500 | pub fn render(&self, renderer: &Renderer, rename_state: Option<&RenameState>) -> anyhow::Result<()> { | 563 | pub fn render(&self, renderer: &Renderer, rename_state: Option<&RenameState>) -> anyhow::Result<()> { |
| 501 | let theme = renderer.theme(); | 564 | let theme = renderer.theme(); |
| 502 | - let visible = self.visible_entries(); | 565 | + let visible_count = self.visible_count(); |
| 503 | let visible_rows = self.visible_rows(); | 566 | let visible_rows = self.visible_rows(); |
| 504 | | 567 | |
| 505 | let start_index = self.scroll_offset * self.columns; | 568 | let start_index = self.scroll_offset * self.columns; |
| 506 | - let end_index = (start_index + visible_rows * self.columns).min(visible.len()); | 569 | + let end_index = (start_index + visible_rows * self.columns).min(visible_count); |
| | 570 | + |
| | 571 | + // Pre-compute colors to avoid parsing hex strings in the loop |
| | 572 | + let dir_color = Color::new(0.36, 0.62, 0.85, 1.0); // #5c9fd8 |
| | 573 | + let symlink_color = Color::new(0.78, 0.47, 0.87, 1.0); // #c678dd |
| | 574 | + |
| | 575 | + // Pre-compute icon font size |
| | 576 | + let icon_font_size = match self.icon_size { |
| | 577 | + IconSize::Small => 24.0, |
| | 578 | + IconSize::Medium => 32.0, |
| | 579 | + IconSize::Large => 48.0, |
| | 580 | + }; |
| | 581 | + let name_font_size = self.icon_size.font_size(); |
| | 582 | + let icon_size_px = self.icon_size.icon_size(); |
| 507 | | 583 | |
| 508 | for i in start_index..end_index { | 584 | for i in start_index..end_index { |
| 509 | - let entry = visible[i]; | 585 | + let entry = match self.visible_entry(i) { |
| | 586 | + Some(e) => e, |
| | 587 | + None => continue, |
| | 588 | + }; |
| 510 | let cell = self.cell_bounds(i); | 589 | let cell = self.cell_bounds(i); |
| 511 | | 590 | |
| 512 | // Skip cells outside visible area | 591 | // Skip cells outside visible area |
@@ -545,18 +624,12 @@ impl GridView { |
| 545 | theme.selection_foreground | 624 | theme.selection_foreground |
| 546 | } else { | 625 | } else { |
| 547 | match entry.entry_type { | 626 | match entry.entry_type { |
| 548 | - EntryType::Directory => Color::from_hex("#5c9fd8").unwrap_or(theme.item_foreground), | 627 | + EntryType::Directory => dir_color, |
| 549 | - EntryType::Symlink => Color::from_hex("#c678dd").unwrap_or(theme.item_foreground), | 628 | + EntryType::Symlink => symlink_color, |
| 550 | _ => theme.item_foreground, | 629 | _ => theme.item_foreground, |
| 551 | } | 630 | } |
| 552 | }; | 631 | }; |
| 553 | | 632 | |
| 554 | - // Scale icon font size based on icon size setting | | |
| 555 | - let icon_font_size = match self.icon_size { | | |
| 556 | - IconSize::Small => 24.0, | | |
| 557 | - IconSize::Medium => 32.0, | | |
| 558 | - IconSize::Large => 48.0, | | |
| 559 | - }; | | |
| 560 | let icon_style = TextStyle::new() | 633 | let icon_style = TextStyle::new() |
| 561 | .font_family(&theme.font_family) | 634 | .font_family(&theme.font_family) |
| 562 | .font_size(icon_font_size) | 635 | .font_size(icon_font_size) |
@@ -568,12 +641,11 @@ impl GridView { |
| 568 | renderer.text_centered(icon, Point::new(icon_center_x, icon_center_y), &icon_style)?; | 641 | renderer.text_centered(icon, Point::new(icon_center_x, icon_center_y), &icon_style)?; |
| 569 | | 642 | |
| 570 | // Rectangle for the text area below the icon | 643 | // Rectangle for the text area below the icon |
| 571 | - let icon_size = self.icon_size.icon_size(); | | |
| 572 | let text_rect = Rect::new( | 644 | let text_rect = Rect::new( |
| 573 | cell.x + 4, | 645 | cell.x + 4, |
| 574 | - cell.y + icon_size as i32 + 8, | 646 | + cell.y + icon_size_px as i32 + 8, |
| 575 | cell.width - 8, | 647 | cell.width - 8, |
| 576 | - cell.height - icon_size - 12, | 648 | + cell.height - icon_size_px - 12, |
| 577 | ); | 649 | ); |
| 578 | | 650 | |
| 579 | if is_renaming { | 651 | if is_renaming { |
@@ -591,25 +663,22 @@ impl GridView { |
| 591 | theme.item_foreground | 663 | theme.item_foreground |
| 592 | }; | 664 | }; |
| 593 | | 665 | |
| 594 | - let name_font_size = self.icon_size.font_size(); | 666 | + // Use Pango CENTER alignment for proper text centering |
| 595 | let name_style = TextStyle::new() | 667 | let name_style = TextStyle::new() |
| 596 | .font_family(&theme.font_family) | 668 | .font_family(&theme.font_family) |
| 597 | .font_size(name_font_size) | 669 | .font_size(name_font_size) |
| 598 | - .color(name_color); | 670 | + .color(name_color) |
| 599 | - | | |
| 600 | - // Use Pango CENTER alignment for proper text centering (like Dolphin/Nautilus) | | |
| 601 | - let name_style = name_style.clone() | | |
| 602 | .align(TextAlign::Center) | 671 | .align(TextAlign::Center) |
| 603 | .ellipsize(true) | 672 | .ellipsize(true) |
| 604 | .max_width((cell.width - 8) as i32); | 673 | .max_width((cell.width - 8) as i32); |
| 605 | | 674 | |
| 606 | - // Add "@" suffix for symlinks | 675 | + // Render text - avoid allocation for non-symlinks |
| 607 | - let display_name = if entry.is_symlink { | 676 | + if entry.is_symlink { |
| 608 | - format!("{}@", entry.name) | 677 | + let display_name = format!("{}@", entry.name); |
| | 678 | + renderer.text_in_rect(&display_name, text_rect, &name_style)?; |
| 609 | } else { | 679 | } else { |
| 610 | - entry.name.clone() | 680 | + renderer.text_in_rect(&entry.name, text_rect, &name_style)?; |
| 611 | - }; | 681 | + } |
| 612 | - renderer.text_in_rect(&display_name, text_rect, &name_style)?; | | |
| 613 | } | 682 | } |
| 614 | } | 683 | } |
| 615 | | 684 | |