diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 956709f4..3a3c4ef9 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -215,64 +215,73 @@ 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"); - for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); - a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); - let r = r.shrink2(vec2(8.0, 0.0)); - a11y::ring_around(ui, r, &resp); - let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); - if divider && !renaming { - // A divider is a line between pages: it opens nothing and takes no layers. - ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); - } else if resp.hovered() || id == cur { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - if dragging.is_some() - && id != cur - && !divider - // The gap between rows counts too. - && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) - { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { - move_to = Some((id, name.clone())); + // A long list scrolls in its own area, so the layers keep their room below it. + let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); + let moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); + let r = r.shrink2(vec2(8.0, 0.0)); + a11y::ring_around(ui, r, &resp); + let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); + if divider && !renaming { + // A divider is a line between pages: it opens nothing and takes no layers. + ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); + } else if resp.hovered() || id == cur { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - } - if renaming { - let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); - if let Some(new) = - crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) }) + if dragging.is_some() + && id != cur + && !divider + // The gap between rows counts too. + && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) { - rename_done = Some((id, new)); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + move_to = Some((id, name.clone())); + } } - } else if !divider { - let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); - } - if resp.clicked() && !divider { - select = Some(id); - } - if resp.double_clicked() { - app.ui.rename_page = Some((id, name.clone())); - ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); - } - crate::right::context_popup(&resp).show(|ui| { - if ui.button(tr("Rename page")).clicked() { + if renaming { + let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); + if let Some(new) = crate::widgets::text_field_inline( + ui, + Id::new(("pagename", id)), + &name, + tr, + if id == cur { semibold(11.0) } else { regular(11.0) }, + ) { + rename_done = Some((id, new)); + } + } else if !divider { + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); + } + if resp.clicked() && !divider { + select = Some(id); + } + if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); - ui.close(); + ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); } - if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); - ui.close(); - } - if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); - ui.close(); - } - }); - } - if let Some((from, to)) = reorder.finish(ui) + crate::right::context_popup(&resp).show(|ui| { + if ui.button(tr("Rename page")).clicked() { + app.ui.rename_page = Some((id, name.clone())); + ui.close(); + } + if ui.button(tr("Duplicate page")).clicked() { + app.run("page.duplicate", json!({"id": id})); + ui.close(); + } + if ui.button(tr("Delete page")).clicked() { + app.run("page.delete", json!({"id": id})); + ui.close(); + } + }); + } + reorder.finish(ui) + }); + if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) { app.run("page.move", json!({"id": id, "index": to})); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e1fdecc4..b82e8d7b 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -226,3 +226,47 @@ fn pages_drag_to_reorder_and_dividers_open_nothing() { assert_eq!(u.app.session.page().unwrap(), p1, "clicking a divider opens nothing"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// The page opened by a click on each spot from just under the pages header down to `to`. +fn pages_down_to(u: &mut Ui, to: f32) -> Vec { + let mut opened = Vec::new(); + for y in (190..to as i32).step_by(8) { + click(u, Pos2::new(150.0, y as f32), Modifiers::NONE); + opened.push(u.app.session.page().unwrap()); + } + opened +} + +#[test] +fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..59 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + 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); + // The layers list stays on screen under 60 pages. + let layer = u.row_at(rect); + // The wheel over the pages list scrolls it to its last page. + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(Pos2::new(150.0, 200.0)), wheel.clone()]); + } + for _ in 0..30 { + u.frame(vec![]); + } + let opened = pages_down_to(&mut u, layer.y - 60.0); + assert!(opened.contains(&last), "the last page can be reached"); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 30, "the list scrolled past its first pages (top row: page {top})"); +}