fix: flashing ui bug when scrolling in jv
2 files changed, +173 -172
+152-159src/views/browser.rs
| @@ -38,8 +38,9 @@ struct Entry { | ||
| 38 | 38 | pub struct BrowserView { |
| 39 | 39 | path: Vec<PathSegment>, |
| 40 | 40 | selection: usize, |
| 41 | - scroll_to_selection: bool, | |
| 42 | 41 | restore_key: Option<String>, |
| 42 | + // Scrollable list for the center column | |
| 43 | + current_list: crate::widgets::scrollable_list::ScrollableList, | |
| 43 | 44 | // jq bar |
| 44 | 45 | jq_bar: crate::widgets::jq_bar::JqBar, |
| 45 | 46 | jq_synced: bool, |
| @@ -53,8 +54,9 @@ impl BrowserView { | ||
| 53 | 54 | Self { |
| 54 | 55 | path: Vec::new(), |
| 55 | 56 | selection: 0, |
| 56 | - scroll_to_selection: false, | |
| 57 | + | |
| 57 | 58 | restore_key: None, |
| 59 | + current_list: crate::widgets::scrollable_list::ScrollableList::new(), | |
| 58 | 60 | jq_bar: crate::widgets::jq_bar::JqBar::new(), |
| 59 | 61 | jq_synced: true, |
| 60 | 62 | jq_result: None, |
| @@ -95,7 +97,7 @@ impl BrowserView { | ||
| 95 | 97 | if let Some(idx) = display_names.iter().position(|n| n == display_name) { |
| 96 | 98 | self.selection = idx; |
| 97 | 99 | } |
| 98 | - self.scroll_to_selection = true; | |
| 100 | + // selection changed | |
| 99 | 101 | self.sync_jq_from_path(); |
| 100 | 102 | } |
| 101 | 103 | |
| @@ -207,7 +209,7 @@ impl BrowserView { | ||
| 207 | 209 | if !jq_has_focus { |
| 208 | 210 | let action = crate::widgets::read_miller_keys(ui, false); |
| 209 | 211 | if crate::widgets::apply_selection(&mut self.selection, action, current_entries.len()) { |
| 210 | - self.scroll_to_selection = true; | |
| 212 | + // selection changed | |
| 211 | 213 | self.sync_jq_from_path(); |
| 212 | 214 | } |
| 213 | 215 | if action == crate::widgets::MillerAction::Enter { |
| @@ -217,7 +219,7 @@ impl BrowserView { | ||
| 217 | 219 | // Root: enter a file by name |
| 218 | 220 | self.path.push(PathSegment::Key(entry.label.clone())); |
| 219 | 221 | self.selection = 0; |
| 220 | - self.scroll_to_selection = true; | |
| 222 | + // selection changed | |
| 221 | 223 | self.jq_synced = true; |
| 222 | 224 | self.sync_jq_from_path(); |
| 223 | 225 | } else { |
| @@ -312,7 +314,7 @@ impl BrowserView { | ||
| 312 | 314 | // Handle click actions |
| 313 | 315 | if let Some(idx) = clicked_entry { |
| 314 | 316 | self.selection = idx; |
| 315 | - self.scroll_to_selection = false; | |
| 317 | + // scroll handled by ScrollableList | |
| 316 | 318 | self.sync_jq_from_path(); |
| 317 | 319 | } |
| 318 | 320 | if let Some(idx) = dbl_clicked_entry { |
| @@ -322,7 +324,7 @@ impl BrowserView { | ||
| 322 | 324 | if self.path.is_empty() { |
| 323 | 325 | self.path.push(PathSegment::Key(entry.label.clone())); |
| 324 | 326 | self.selection = 0; |
| 325 | - self.scroll_to_selection = true; | |
| 327 | + // selection changed | |
| 326 | 328 | self.jq_synced = true; |
| 327 | 329 | self.sync_jq_from_path(); |
| 328 | 330 | } else { |
| @@ -373,7 +375,7 @@ impl BrowserView { | ||
| 373 | 375 | self.jq_synced = true; |
| 374 | 376 | self.jq_result = None; |
| 375 | 377 | self.jq_error = None; |
| 376 | - self.scroll_to_selection = true; | |
| 378 | + // selection changed | |
| 377 | 379 | } |
| 378 | 380 | } |
| 379 | 381 | } |
| @@ -386,7 +388,7 @@ impl BrowserView { | ||
| 386 | 388 | self.jq_synced = true; |
| 387 | 389 | self.jq_result = None; |
| 388 | 390 | self.jq_error = None; |
| 389 | - self.scroll_to_selection = true; | |
| 391 | + // selection changed | |
| 390 | 392 | } |
| 391 | 393 | } else if resp.run { |
| 392 | 394 | // Manual Enter (no completion) — try as path, then as jq query |
| @@ -397,7 +399,7 @@ impl BrowserView { | ||
| 397 | 399 | self.jq_synced = true; |
| 398 | 400 | self.jq_result = None; |
| 399 | 401 | self.jq_error = None; |
| 400 | - self.scroll_to_selection = true; | |
| 402 | + // selection changed | |
| 401 | 403 | return; |
| 402 | 404 | } |
| 403 | 405 | } |
| @@ -499,9 +501,6 @@ impl BrowserView { | ||
| 499 | 501 | current_value: &serde_json::Value, |
| 500 | 502 | height: f32, |
| 501 | 503 | ) -> (Option<usize>, Option<usize>) { |
| 502 | - let mut clicked = None; | |
| 503 | - let mut dbl_clicked = None; | |
| 504 | - | |
| 505 | 504 | if entries.is_empty() { |
| 506 | 505 | ui.centered_and_justified(|ui| { |
| 507 | 506 | ui.label( |
| @@ -513,149 +512,137 @@ impl BrowserView { | ||
| 513 | 512 | return (None, None); |
| 514 | 513 | } |
| 515 | 514 | |
| 516 | - let scroll_sel = self.scroll_to_selection; | |
| 517 | - self.scroll_to_selection = false; | |
| 518 | - let selection = self.selection; | |
| 519 | - | |
| 520 | - egui::ScrollArea::vertical() | |
| 521 | - .id_salt("browser_current") | |
| 522 | - .auto_shrink(false) | |
| 523 | - .max_height(height) | |
| 524 | - .show(ui, |ui| { | |
| 525 | - for i in 0..entries.len() { | |
| 526 | - let entry = &entries[i]; | |
| 527 | - let is_selected = i == selection; | |
| 528 | - | |
| 529 | - let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), i); | |
| 530 | - | |
| 531 | - let bg = if is_selected { | |
| 532 | - CatppuccinMocha::SURFACE0 | |
| 533 | - } else if is_hovered { | |
| 534 | - egui::Color32::from_rgba_unmultiplied( | |
| 535 | - entry.color.r(), entry.color.g(), entry.color.b(), 15, | |
| 536 | - ) | |
| 537 | - } else { | |
| 538 | - egui::Color32::TRANSPARENT | |
| 539 | - }; | |
| 540 | - | |
| 541 | - let font_size = if is_hovered && !is_selected { 12.5 } else { 12.0 }; | |
| 542 | - let type_size = if is_hovered && !is_selected { 11.5 } else { 11.0 }; | |
| 543 | - let preview_size = if is_hovered && !is_selected { 11.5 } else { 11.0 }; | |
| 515 | + let row_height = ui.text_style_height(&egui::TextStyle::Monospace) + 6.0; | |
| 516 | + let mut clicked = None; | |
| 517 | + let mut dbl_clicked: Option<usize> = None; | |
| 518 | + | |
| 519 | + self.current_list.selection = self.selection; | |
| 520 | + self.current_list.show( | |
| 521 | + ui, | |
| 522 | + "browser_current", | |
| 523 | + entries.len(), | |
| 524 | + row_height, | |
| 525 | + Some(height), | |
| 526 | + &mut |ui, i, is_selected| { | |
| 527 | + let entry = &entries[i]; | |
| 528 | + let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), i); | |
| 529 | + | |
| 530 | + let bg = if is_selected { | |
| 531 | + CatppuccinMocha::SURFACE0 | |
| 532 | + } else if is_hovered { | |
| 533 | + egui::Color32::from_rgba_unmultiplied( | |
| 534 | + entry.color.r(), entry.color.g(), entry.color.b(), 15, | |
| 535 | + ) | |
| 536 | + } else { | |
| 537 | + egui::Color32::TRANSPARENT | |
| 538 | + }; | |
| 544 | 539 | |
| 545 | - let mut copy_clicked = false; | |
| 540 | + let font_size = if is_hovered && !is_selected { 12.5 } else { 12.0 }; | |
| 541 | + let type_size = if is_hovered && !is_selected { 11.5 } else { 11.0 }; | |
| 542 | + let preview_size = if is_hovered && !is_selected { 11.5 } else { 11.0 }; | |
| 543 | + | |
| 544 | + // Hit-test copy button from previous frame | |
| 545 | + let copy_btn_id = ui.id().with(("copy_btn", i)); | |
| 546 | + if let Some(prev_rect) = ui.ctx().data(|d| d.get_temp::<egui::Rect>(copy_btn_id)) { | |
| 547 | + if ui.rect_contains_pointer(prev_rect) { | |
| 548 | + ui.painter().rect_filled(prev_rect, 3.0, CatppuccinMocha::SURFACE1); | |
| 549 | + ui.painter().text( | |
| 550 | + prev_rect.center(), | |
| 551 | + egui::Align2::CENTER_CENTER, | |
| 552 | + egui_phosphor::regular::COPY, | |
| 553 | + egui::FontId::proportional(12.0), | |
| 554 | + CatppuccinMocha::TEXT, | |
| 555 | + ); | |
| 556 | + if ui.input(|inp| inp.pointer.any_click()) { | |
| 557 | + if let Some(v) = child_value(current_value, i, &entry.label) { | |
| 558 | + ui.ctx().copy_text(copy_value_str(v)); | |
| 559 | + } | |
| 560 | + } | |
| 561 | + } | |
| 562 | + } | |
| 546 | 563 | |
| 547 | - // Hit-test copy button from previous frame's stored rect | |
| 548 | - let copy_btn_id = ui.id().with(("copy_btn", i)); | |
| 549 | - if let Some(prev_rect) = ui.ctx().data(|d| d.get_temp::<egui::Rect>(copy_btn_id)) { | |
| 550 | - if ui.rect_contains_pointer(prev_rect) { | |
| 551 | - ui.painter().rect_filled(prev_rect, 3.0, CatppuccinMocha::SURFACE1); | |
| 564 | + let r = egui::Frame::new() | |
| 565 | + .fill(bg) | |
| 566 | + .corner_radius(4.0) | |
| 567 | + .inner_margin(egui::Margin::symmetric(6, 1)) | |
| 568 | + .show(ui, |ui| { | |
| 569 | + ui.horizontal(|ui| { | |
| 570 | + // Copy icon | |
| 571 | + let icon_rect = egui::Rect::from_min_size( | |
| 572 | + ui.cursor().left_top(), | |
| 573 | + egui::vec2(16.0, ui.min_rect().height().max(14.0)), | |
| 574 | + ); | |
| 552 | 575 | ui.painter().text( |
| 553 | - prev_rect.center(), | |
| 576 | + icon_rect.center(), | |
| 554 | 577 | egui::Align2::CENTER_CENTER, |
| 555 | 578 | egui_phosphor::regular::COPY, |
| 556 | 579 | egui::FontId::proportional(12.0), |
| 557 | - CatppuccinMocha::TEXT, | |
| 580 | + CatppuccinMocha::OVERLAY0, | |
| 581 | + ); | |
| 582 | + ui.add_space(16.0); | |
| 583 | + ui.ctx().data_mut(|d| d.insert_temp(copy_btn_id, icon_rect)); | |
| 584 | + | |
| 585 | + // Type icon | |
| 586 | + ui.label( | |
| 587 | + RichText::new(entry.type_icon) | |
| 588 | + .color(entry.color) | |
| 589 | + .family(egui::FontFamily::Monospace) | |
| 590 | + .size(font_size), | |
| 558 | 591 | ); |
| 559 | - egui::show_tooltip_at_pointer( | |
| 560 | - ui.ctx(), | |
| 561 | - ui.layer_id(), | |
| 562 | - ui.id().with(("copy_tip", i)), | |
| 592 | + // Label | |
| 593 | + let label_color = if is_selected || is_hovered { | |
| 594 | + entry.color | |
| 595 | + } else { | |
| 596 | + CatppuccinMocha::SUBTEXT0 | |
| 597 | + }; | |
| 598 | + ui.label( | |
| 599 | + RichText::new(&entry.label) | |
| 600 | + .color(label_color) | |
| 601 | + .family(egui::FontFamily::Monospace) | |
| 602 | + .size(font_size), | |
| 603 | + ); | |
| 604 | + // Type label | |
| 605 | + ui.label( | |
| 606 | + RichText::new(&entry.type_label) | |
| 607 | + .color(CatppuccinMocha::OVERLAY0) | |
| 608 | + .family(egui::FontFamily::Monospace) | |
| 609 | + .size(type_size), | |
| 610 | + ); | |
| 611 | + // Preview (right-aligned) | |
| 612 | + ui.with_layout( | |
| 613 | + egui::Layout::right_to_left(egui::Align::Center), | |
| 563 | 614 | |ui| { |
| 564 | - ui.label("Copy value (c or Ctrl+C)"); | |
| 615 | + let preview = if entry.preview.len() > 24 { | |
| 616 | + format!("{}...", &entry.preview[..21]) | |
| 617 | + } else { | |
| 618 | + entry.preview.clone() | |
| 619 | + }; | |
| 620 | + ui.label( | |
| 621 | + RichText::new(preview) | |
| 622 | + .color(entry.color) | |
| 623 | + .family(egui::FontFamily::Monospace) | |
| 624 | + .size(preview_size), | |
| 625 | + ); | |
| 565 | 626 | }, |
| 566 | 627 | ); |
| 567 | - if ui.input(|inp| inp.pointer.any_click()) { | |
| 568 | - copy_clicked = true; | |
| 569 | - if let Some(v) = child_value(current_value, i, &entry.label) { | |
| 570 | - ui.ctx().copy_text(copy_value_str(v)); | |
| 571 | - } | |
| 572 | - } | |
| 573 | - } | |
| 574 | - } | |
| 575 | - | |
| 576 | - let r = egui::Frame::new() | |
| 577 | - .fill(bg) | |
| 578 | - .corner_radius(4.0) | |
| 579 | - .inner_margin(egui::Margin::symmetric(6, 1)) | |
| 580 | - .show(ui, |ui| { | |
| 581 | - ui.horizontal(|ui| { | |
| 582 | - // Copy icon — paint only, no space allocation | |
| 583 | - let icon_rect = egui::Rect::from_min_size( | |
| 584 | - ui.cursor().left_top(), | |
| 585 | - egui::vec2(16.0, ui.min_rect().height().max(14.0)), | |
| 586 | - ); | |
| 587 | - ui.painter().text( | |
| 588 | - icon_rect.center(), | |
| 589 | - egui::Align2::CENTER_CENTER, | |
| 590 | - egui_phosphor::regular::COPY, | |
| 591 | - egui::FontId::proportional(12.0), | |
| 592 | - CatppuccinMocha::OVERLAY0, | |
| 593 | - ); | |
| 594 | - ui.add_space(16.0); | |
| 595 | - ui.ctx().data_mut(|d| d.insert_temp(copy_btn_id, icon_rect)); | |
| 596 | - | |
| 597 | - // Type icon | |
| 598 | - ui.label( | |
| 599 | - RichText::new(entry.type_icon) | |
| 600 | - .color(entry.color) | |
| 601 | - .family(egui::FontFamily::Monospace) | |
| 602 | - .size(font_size), | |
| 603 | - ); | |
| 604 | - // Label (key or index) | |
| 605 | - let label_color = if is_selected || is_hovered { | |
| 606 | - entry.color | |
| 607 | - } else { | |
| 608 | - CatppuccinMocha::SUBTEXT0 | |
| 609 | - }; | |
| 610 | - ui.label( | |
| 611 | - RichText::new(&entry.label) | |
| 612 | - .color(label_color) | |
| 613 | - .family(egui::FontFamily::Monospace) | |
| 614 | - .size(font_size), | |
| 615 | - ); | |
| 616 | - // Type label | |
| 617 | - ui.label( | |
| 618 | - RichText::new(&entry.type_label) | |
| 619 | - .color(CatppuccinMocha::OVERLAY0) | |
| 620 | - .family(egui::FontFamily::Monospace) | |
| 621 | - .size(type_size), | |
| 622 | - ); | |
| 623 | - // Value preview (right-aligned) | |
| 624 | - ui.with_layout( | |
| 625 | - egui::Layout::right_to_left(egui::Align::Center), | |
| 626 | - |ui| { | |
| 627 | - let preview = if entry.preview.len() > 24 { | |
| 628 | - format!("{}...", &entry.preview[..21]) | |
| 629 | - } else { | |
| 630 | - entry.preview.clone() | |
| 631 | - }; | |
| 632 | - ui.label( | |
| 633 | - RichText::new(preview) | |
| 634 | - .color(entry.color) | |
| 635 | - .family(egui::FontFamily::Monospace) | |
| 636 | - .size(preview_size), | |
| 637 | - ); | |
| 638 | - }, | |
| 639 | - ); | |
| 640 | - }); | |
| 641 | 628 | }); |
| 629 | + }); | |
| 642 | 630 | |
| 643 | - let response = r.response.interact(egui::Sense::click()); | |
| 644 | - crate::widgets::store_hover(ui.ctx(), row_id, response.hovered()); | |
| 645 | - | |
| 646 | - if response.clicked() && !copy_clicked { | |
| 647 | - clicked = Some(i); | |
| 648 | - } | |
| 649 | - if response.double_clicked() && !copy_clicked { | |
| 650 | - dbl_clicked = Some(i); | |
| 651 | - } | |
| 631 | + let response = r.response.interact(egui::Sense::click()); | |
| 632 | + crate::widgets::store_hover(ui.ctx(), row_id, response.hovered()); | |
| 652 | 633 | |
| 653 | - if is_selected && scroll_sel { | |
| 654 | - response.scroll_to_me(Some(egui::Align::Center)); | |
| 655 | - } | |
| 634 | + if response.double_clicked() { | |
| 635 | + dbl_clicked = Some(i); | |
| 656 | 636 | } |
| 657 | - }); | |
| 658 | 637 | |
| 638 | + response | |
| 639 | + }, | |
| 640 | + &mut |idx| { | |
| 641 | + clicked = Some(idx); | |
| 642 | + }, | |
| 643 | + ); | |
| 644 | + | |
| 645 | + self.selection = self.current_list.selection; | |
| 659 | 646 | (clicked, dbl_clicked) |
| 660 | 647 | } |
| 661 | 648 | |
| @@ -936,7 +923,7 @@ impl BrowserView { | ||
| 936 | 923 | _ => return, |
| 937 | 924 | } |
| 938 | 925 | self.selection = 0; |
| 939 | - self.scroll_to_selection = true; | |
| 926 | + // selection changed | |
| 940 | 927 | self.sync_jq_from_path(); |
| 941 | 928 | } |
| 942 | 929 | } |
| @@ -953,7 +940,7 @@ impl BrowserView { | ||
| 953 | 940 | self.restore_key = Some(k); |
| 954 | 941 | } |
| 955 | 942 | } |
| 956 | - self.scroll_to_selection = true; | |
| 943 | + // selection changed | |
| 957 | 944 | self.sync_jq_from_path(); |
| 958 | 945 | } |
| 959 | 946 | } |
| @@ -1008,7 +995,7 @@ fn resolve_from_files<'a>( | ||
| 1008 | 995 | |
| 1009 | 996 | /// Build entries for the root file list (no JSON cloning). |
| 1010 | 997 | fn build_file_entries(files: &[(String, serde_json::Value)]) -> Vec<Entry> { |
| 1011 | - files.iter().map(|(name, val)| { | |
| 998 | + let mut entries: Vec<Entry> = files.iter().map(|(name, val)| { | |
| 1012 | 999 | let display = name.strip_suffix(".json").unwrap_or(name); |
| 1013 | 1000 | let (icon, color) = type_icon_color(val); |
| 1014 | 1001 | Entry { |
| @@ -1019,7 +1006,9 @@ fn build_file_entries(files: &[(String, serde_json::Value)]) -> Vec<Entry> { | ||
| 1019 | 1006 | color, |
| 1020 | 1007 | is_container: true, |
| 1021 | 1008 | } |
| 1022 | - }).collect() | |
| 1009 | + }).collect(); | |
| 1010 | + entries.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase())); | |
| 1011 | + entries | |
| 1023 | 1012 | } |
| 1024 | 1013 | |
| 1025 | 1014 | fn resolve_path<'a>( |
| @@ -1125,20 +1114,24 @@ fn copy_value_str(v: &serde_json::Value) -> String { | ||
| 1125 | 1114 | |
| 1126 | 1115 | fn build_entries(value: &serde_json::Value) -> Vec<Entry> { |
| 1127 | 1116 | match value { |
| 1128 | - serde_json::Value::Object(map) => map | |
| 1129 | - .iter() | |
| 1130 | - .map(|(k, v)| { | |
| 1131 | - let (icon, color) = type_icon_color(v); | |
| 1132 | - Entry { | |
| 1133 | - label: k.clone(), | |
| 1134 | - type_icon: icon, | |
| 1135 | - type_label: type_label(v), | |
| 1136 | - preview: value_preview(v), | |
| 1137 | - color, | |
| 1138 | - is_container: v.is_object() || v.is_array(), | |
| 1139 | - } | |
| 1140 | - }) | |
| 1141 | - .collect(), | |
| 1117 | + serde_json::Value::Object(map) => { | |
| 1118 | + let mut entries: Vec<Entry> = map | |
| 1119 | + .iter() | |
| 1120 | + .map(|(k, v)| { | |
| 1121 | + let (icon, color) = type_icon_color(v); | |
| 1122 | + Entry { | |
| 1123 | + label: k.clone(), | |
| 1124 | + type_icon: icon, | |
| 1125 | + type_label: type_label(v), | |
| 1126 | + preview: value_preview(v), | |
| 1127 | + color, | |
| 1128 | + is_container: v.is_object() || v.is_array(), | |
| 1129 | + } | |
| 1130 | + }) | |
| 1131 | + .collect(); | |
| 1132 | + entries.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase())); | |
| 1133 | + entries | |
| 1134 | + } | |
| 1142 | 1135 | serde_json::Value::Array(arr) => arr |
| 1143 | 1136 | .iter() |
| 1144 | 1137 | .enumerate() |
+21-13src/widgets/scrollable_list.rs
| @@ -9,6 +9,8 @@ pub struct ScrollableList { | ||
| 9 | 9 | pub selection: usize, |
| 10 | 10 | prev_selection: usize, |
| 11 | 11 | force_scroll: bool, |
| 12 | + /// When set, force this scroll offset on next show() to bring an off-screen selection into view. | |
| 13 | + pending_offset: Option<f32>, | |
| 12 | 14 | } |
| 13 | 15 | |
| 14 | 16 | impl ScrollableList { |
| @@ -17,6 +19,7 @@ impl ScrollableList { | ||
| 17 | 19 | selection: 0, |
| 18 | 20 | prev_selection: usize::MAX, |
| 19 | 21 | force_scroll: false, |
| 22 | + pending_offset: None, | |
| 20 | 23 | } |
| 21 | 24 | } |
| 22 | 25 | |
| @@ -62,38 +65,43 @@ impl ScrollableList { | ||
| 62 | 65 | self.prev_selection = self.selection; |
| 63 | 66 | self.force_scroll = false; |
| 64 | 67 | |
| 65 | - let visible_h = max_height.unwrap_or(f32::MAX); | |
| 66 | - | |
| 67 | - // Set scroll offset directly so show_rows renders the selected row. | |
| 68 | - let scroll_offset = if needs_scroll && visible_h < f32::MAX { | |
| 69 | - let target_y = self.selection as f32 * row_height; | |
| 70 | - let centered = (target_y - visible_h / 2.0 + row_height / 2.0).max(0.0); | |
| 71 | - Some(centered) | |
| 72 | - } else { | |
| 73 | - None | |
| 74 | - }; | |
| 75 | - | |
| 76 | 68 | let mut area = egui::ScrollArea::vertical() |
| 77 | 69 | .id_salt(id_salt) |
| 78 | 70 | .auto_shrink(false); |
| 79 | 71 | if let Some(h) = max_height { |
| 80 | 72 | area = area.max_height(h); |
| 81 | 73 | } |
| 82 | - if let Some(offset) = scroll_offset { | |
| 74 | + // Apply pending offset from a previous out-of-range jump | |
| 75 | + if let Some(offset) = self.pending_offset.take() { | |
| 83 | 76 | area = area.vertical_scroll_offset(offset); |
| 84 | 77 | } |
| 85 | 78 | |
| 86 | 79 | let mut clicked_idx: Option<usize> = None; |
| 80 | + let selection = self.selection; | |
| 87 | 81 | |
| 88 | 82 | area.show_rows(ui, row_height, count, |ui, range| { |
| 83 | + let in_range = range.contains(&selection); | |
| 84 | + | |
| 89 | 85 | for i in range { |
| 90 | - let is_selected = i == self.selection; | |
| 86 | + let is_selected = i == selection; | |
| 91 | 87 | let r = render_row(ui, i, is_selected); |
| 92 | 88 | |
| 89 | + // scroll_to_me works when the item is in/near the rendered range | |
| 90 | + if needs_scroll && is_selected { | |
| 91 | + r.scroll_to_me(Some(egui::Align::Center)); | |
| 92 | + } | |
| 93 | 93 | if r.clicked() { |
| 94 | 94 | clicked_idx = Some(i); |
| 95 | 95 | } |
| 96 | 96 | } |
| 97 | + | |
| 98 | + // If selection was outside rendered range, queue an offset jump for next frame | |
| 99 | + if needs_scroll && !in_range { | |
| 100 | + if let Some(h) = max_height { | |
| 101 | + let target_y = selection as f32 * row_height; | |
| 102 | + self.pending_offset = Some((target_y - h / 2.0 + row_height / 2.0).max(0.0)); | |
| 103 | + } | |
| 104 | + } | |
| 97 | 105 | }); |
| 98 | 106 | |
| 99 | 107 | if let Some(idx) = clicked_idx { |