From 1c79c1646fefb531006459d8a2c7a53fcc43c06a Mon Sep 17 00:00:00 2001 From: Siade Lucas Date: Tue, 20 Oct 2026 12:53:21 -0300 Subject: [PATCH 1/2] ui: scroll active page into view when selection changes --- contributors/people.toml | 4 +++ crates/ui-egui/src/layers.rs | 5 ++++ crates/ui-egui/src/layers_behaviour_tests.rs | 27 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +++ 4 files changed, 39 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b9172891..27307935 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -24,6 +24,10 @@ display_name = "VanTan" real_name = "Tyler Jennings" display_name = "tylerjenningsw" +[people.SiadeLucas] +real_name = "Siade Lucas" +sync_github_name = true + [people.Silvertree2010] display_name = "Netter" sync_github_name = false diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index dfab115a..212ae413 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -229,6 +229,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); + let scroll_to_cur = app.ui.pages_seen_page != Some(cur); + app.ui.pages_seen_page = Some(cur); // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { @@ -238,6 +240,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if scroll_to_cur && id == cur { + ui.scroll_to_rect(r, None); + } if !filtering { reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); } diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index b963ee82..cb62540c 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -507,3 +507,30 @@ fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() { // A panel too short for both keeps the three rows. assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW); } + +/// Selecting a page that is far down the list automatically scrolls it into view. +#[test] +fn selecting_a_distant_page_scrolls_it_into_view() { + let mut s = Session::new(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..40 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let first = pages[0]; + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let layer = u.row_at(rect); + u.frame(vec![]); + // Select the last page. + u.app.run("page.select", json!({"id": last})); + for _ in 0..15 { + u.frame(vec![]); + } + assert_eq!(u.app.ui.pages_seen_page, Some(last)); + let opened = pages_down_to(&mut u, layer.y - 60.0); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 20, "the list scrolled past its first pages to reveal the selection (top row: page {top})"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 072aec62..fb44b60a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -495,6 +495,8 @@ pub struct UiState { pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, + /// The active page when last drawn in the pages list, to scroll it into view when selected. + pub pages_seen_page: Option, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option, @@ -667,6 +669,7 @@ impl Default for UiState { page_search: false, page_filter: String::new(), pages_height: None, + pages_seen_page: None, expanded: BTreeSet::new(), rename: None, rename_page: None, From f53f37dfe0c647871a85dafaf2ee3a9a7c825b0a Mon Sep 17 00:00:00 2001 From: Siade Lucas Date: Tue, 20 Oct 2026 21:28:09 -0300 Subject: [PATCH 2/2] ui: scope page tracker by document UID and update on render Include document UID in pages_seen_page to correctly handle page selection and scroll tracking when switching between documents. Record page as seen only after the active row renders so that pages filtered out by search scroll into view when the filter is cleared. --- crates/ui-egui/src/layers.rs | 15 +++++++--- crates/ui-egui/src/layers_behaviour_tests.rs | 31 +++++++++++++++++++- crates/ui-egui/src/lib.rs | 4 +-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 212ae413..f172b309 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -229,8 +229,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); - let scroll_to_cur = app.ui.pages_seen_page != Some(cur); - app.ui.pages_seen_page = Some(cur); + let page_key = (uid, cur); + let scroll_to_cur = app.ui.pages_seen_page != Some(page_key); + let mut current_row_rendered = false; // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { @@ -240,8 +241,11 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); - if scroll_to_cur && id == cur { - ui.scroll_to_rect(r, None); + if id == cur { + current_row_rendered = true; + if scroll_to_cur { + ui.scroll_to_rect(r, None); + } } if !filtering { reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); @@ -306,6 +310,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } reorder.finish(ui) }); + if current_row_rendered { + app.ui.pages_seen_page = Some(page_key); + } // A drop outside the list's visible area (and the row gap at its edges) isn't on a page row, // even one scrolled out of view. if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) { diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index cb62540c..4d987e66 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -529,8 +529,37 @@ fn selecting_a_distant_page_scrolls_it_into_view() { for _ in 0..15 { u.frame(vec![]); } - assert_eq!(u.app.ui.pages_seen_page, Some(last)); + assert_eq!(u.app.ui.pages_seen_page, Some((u.app.session.ds().unwrap().uid, last))); let opened = pages_down_to(&mut u, layer.y - 60.0); let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); assert!(top > 20, "the list scrolled past its first pages to reveal the selection (top row: page {top})"); } + +/// If the active page is filtered out by search, it is not marked as seen +/// until the filter is cleared and its row actually renders. +#[test] +fn filtered_out_active_page_is_seen_only_after_rendering() { + let mut s = Session::new(); + let p1 = s.doc().unwrap().pages[0]; + let p2 = id(s.execute("page.add", &json!({})).unwrap()); + s.execute("page.rename", &json!({"id": p1, "name": "Alpha"})).unwrap(); + s.execute("page.rename", &json!({"id": p2, "name": "Beta"})).unwrap(); + s.execute("page.select", &json!({"id": p1})).unwrap(); + let uid = s.ds().unwrap().uid; + let mut u = Ui::new(s); + assert_eq!(u.app.ui.pages_seen_page, Some((uid, p1))); + + // Filter to only "Alpha", then select "Beta" (p2). + u.app.ui.page_search = true; + u.app.ui.page_filter = "Alpha".into(); + u.app.run("page.select", json!({"id": p2})); + u.frame(vec![]); + + // Beta was filtered out and not rendered, so pages_seen_page has not recorded it. + assert_ne!(u.app.ui.pages_seen_page, Some((uid, p2))); + + // Clear filter: Beta renders now and is recorded. + u.app.ui.page_filter.clear(); + u.frame(vec![]); + assert_eq!(u.app.ui.pages_seen_page, Some((uid, p2))); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb44b60a..8801238d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -495,8 +495,8 @@ pub struct UiState { pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, - /// The active page when last drawn in the pages list, to scroll it into view when selected. - pub pages_seen_page: Option, + /// The document and active page last drawn in the pages list. + pub pages_seen_page: Option<(u64, NodeId)>, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option,