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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
This commit is contained in:
Andrii Volkov
2026-10-16 11:24:39 +03:00
co-authored by Claude Opus 5.5
parent 23bdebd973
commit afdb21bad6
5 changed files with 132 additions and 4 deletions
+4
View File
@@ -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<f32>,
/// 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,
+49 -2
View File
@@ -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<f32>, 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).
@@ -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<f32> = ((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);
}
+5 -1
View File
@@ -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<f32>,
pub expanded: BTreeSet<NodeId>,
/// The layer being renamed in the layers panel.
pub rename: Option<layers::Rename>,
@@ -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<dyn Services>) -> 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,
+3 -1
View File
@@ -208,13 +208,15 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option<usize>, 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()