fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258)

This commit is contained in:
Marco Arnulfo
2026-10-14 11:15:32 +02:00
parent bcedfeba10
commit bd482ea6dd
2 changed files with 106 additions and 53 deletions
+62 -53
View File
@@ -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}));
@@ -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<NodeId> {
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})");
}