From afdb21bad6e7cfb16fa25e666d43ce65a1c75651 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:24:39 +0300 Subject: [PATCH] fix(ui): drag the line between pages and layers to size the pages list The pages list took a fixed 40% of the left panel (#272). The line under it now drags to make the list taller or shorter: at least three rows, and always leaving the layers their header and three rows. A double-click puts back the default. The main window keeps the height in the preferences, as it does the panel widths, saved once the drag is over. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/engine/src/lib.rs | 4 ++ crates/ui-egui/src/layers.rs | 51 +++++++++++++- crates/ui-egui/src/layers_behaviour_tests.rs | 71 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/windows.rs | 4 +- 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..dcf7e2f2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -606,6 +606,9 @@ pub struct Prefs { /// The widths of the main window's left and right panels (the interface keeps them within its /// limits; other windows have their own while they are open). pub panel_widths: (f32, f32), + /// The height asked for the main window's pages list, dragged at its lower edge (`None`: a + /// share of the panel; the interface keeps it within its limits). + pub pages_height: Option, /// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser /// language). Languages without a catalog show English. pub language: String, @@ -666,6 +669,7 @@ impl Default for Prefs { show_dimensions: true, theme: "system".into(), panel_widths: (240.0, 240.0), + pages_height: None, language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8b614762..a6bbe5f5 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -217,7 +217,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); // 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 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| { 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()); @@ -306,7 +306,54 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { { app.run("page.select", json!({"id": p})); } - crate::widgets::separator(ui); + pages_edge(app, ui, moved.inner_rect.height()); +} + +/// The pages list's height in a panel with `avail` below the Pages header: the height asked for +/// (by default 40% of it), at least three rows, and leaving the layers their header and three rows. +pub(crate) fn pages_list_height(asked: Option, avail: f32) -> f32 { + let least = 3.0 * ROW; + let most = (avail - 40.0 - 3.0 * ROW).max(least); + asked.filter(|h| h.is_finite()).unwrap_or(avail * 0.4).clamp(least, most) +} + +/// The line between the pages and the layers: dragging it sizes the pages list (`shown`: its +/// height now), a double-click puts back the default. The main window keeps it in the preferences. +fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (line, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); + let grab = ui.style().interaction.resize_grab_radius_side; + let id = Id::new("pages_edge"); + let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::click_and_drag()); + let active = resp.hovered() || resp.dragged(); + if active { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + let (width, colour) = if active { (2.0, t.accent) } else { (1.0, t.border) }; + ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(width, colour)); + let asked = if resp.double_clicked() { + Some(None) + } else if resp.dragged() { + // The edge stays under the pointer where it was grabbed. + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(id, shown)); + } + let from: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(shown); + let moved = ui.input(|i| Some(i.pointer.latest_pos()?.y - i.pointer.press_origin()?.y)).unwrap_or(0.0); + Some(Some(from + moved)) + } else { + None + }; + if let Some(h) = asked + && h != app.ui.pages_height + { + app.ui.pages_height = h; + if app.windows.current == 0 { + app.session.prefs.pages_height = h; + app.panels_unsaved = true; + } + } } /// A page rename just asked for by a double click: its field takes the focus when drawn (temp data). diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 43a8b76e..14d625a7 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -331,3 +331,74 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p1)); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// The y of the line between the pages and the layers, above the layers' row at `layer` (found by +/// the resize cursor it shows when hovered). +fn pages_edge_at(u: &mut Ui, layer: f32) -> f32 { + let hits: Vec = ((layer - 120.0) as i32..layer as i32) + .map(|y| y as f32) + .filter(|y| u.frame_output(vec![Event::PointerMoved(Pos2::new(150.0, *y))]).platform_output.cursor_icon == egui::CursorIcon::ResizeVertical) + .collect(); + assert!(!hits.is_empty(), "no edge above {layer}"); + (hits[0] + hits[hits.len() - 1]) / 2.0 +} + +#[test] +fn the_line_between_pages_and_layers_drags_to_size_the_pages_list() { + 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..59 { + s.execute("page.add", &json!({})).unwrap(); + } + let first = s.doc().unwrap().pages[0]; + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let before = u.row_at(rect).y; + let edge = pages_edge_at(&mut u, before); + // A drag down by 150 px makes the pages list taller and moves the layers down with it. + let at = |dy: f32| Pos2::new(150.0, edge + dy); + let button = |p, pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + u.frame(vec![Event::PointerMoved(at(0.0))]); + u.frame(vec![button(at(0.0), true)]); + for k in 1..=15 { + u.frame(vec![Event::PointerMoved(at(10.0 * k as f32))]); + } + u.frame(vec![button(at(150.0), false)]); + for _ in 0..30 { + u.frame(vec![]); + } + let after = u.row_at(rect).y; + assert!((after - before - 150.0).abs() < 3.0, "the layers moved from {before} to {after}"); + // The height is kept for the next start. + let kept = u.app.session.prefs.pages_height.expect("kept in the preferences"); + let mut next = Session::new(); + next.prefs = u.app.session.prefs.clone(); + assert_eq!(crate::FigApp::new(next, Box::new(crate::NoServices)).ui.pages_height, Some(kept)); + // A double-click puts back the default. + let edge = pages_edge_at(&mut u, after); + let p = Pos2::new(150.0, edge); + u.frame(vec![Event::PointerMoved(p)]); + for _ in 0..2 { + u.frame(vec![button(p, true)]); + u.frame(vec![button(p, false)]); + } + for _ in 0..30 { + u.frame(vec![]); + } + assert_eq!(u.app.session.prefs.pages_height, None); + assert!((u.row_at(rect).y - before).abs() < 3.0); +} + +#[test] +fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() { + use crate::layers::{ROW, pages_list_height}; + // The default: 40% of the panel. + assert_eq!(pages_list_height(None, 800.0), 320.0); + // Dragged short or tall, it stays within three rows and the room the layers need. + assert_eq!(pages_list_height(Some(0.0), 800.0), 3.0 * ROW); + assert_eq!(pages_list_height(Some(5000.0), 800.0), 800.0 - 40.0 - 3.0 * ROW); + assert_eq!(pages_list_height(Some(f32::NAN), 800.0), 320.0); + // A panel too short for both keeps the three rows. + assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..21f30f62 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -398,6 +398,8 @@ pub struct UiState { pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, + /// The height asked for the pages list (`None`: a share of the panel). + pub pages_height: Option, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option, @@ -528,6 +530,7 @@ impl Default for UiState { last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, + pages_height: None, expanded: BTreeSet::new(), rename: None, rename_page: None, @@ -646,8 +649,9 @@ impl FigApp { pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; + let pages_height = session.prefs.pages_height; Self { - ui: UiState { left_width, right_width, ..UiState::default() }, + ui: UiState { left_width, right_width, pages_height, ..UiState::default() }, session, canvas: canvas::CanvasState::default(), services, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 510c24f6..b6a21cda 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -208,13 +208,15 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option, path: Option< docs: vec![uid], active: Some(uid), pane: Pane { - // A new window opens like the one it comes from: the same tabs and panel widths. + // A new window opens like the one it comes from: the same tabs, panel widths and pages + // list height. ui: UiState { title_bar: app.ui.title_bar, right_tab: app.ui.right_tab, left_tab: app.ui.left_tab, left_width: app.ui.left_width, right_width: app.ui.right_width, + pages_height: app.ui.pages_height, ..UiState::default() }, ..Pane::default()