mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 04:10:55 +00:00
Merge upstream/main into fix/crop-from-picker-2307
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
@@ -283,25 +283,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.zoomSelection", "Zoom to selection", "{}", false, |s, _| {
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.bounds_of(&ds.selection) {
|
||||
ds.view.fit(b, 256.0);
|
||||
ds.view.fit_selection(b, 256.0);
|
||||
}
|
||||
Ok(json!({"zoom": ds.view.zoom}))
|
||||
}),
|
||||
spec!("view.set", "Set view", "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size)}", false, |s, p| {
|
||||
let v = view(s)?;
|
||||
if let Some(z) = num(p, "zoom") {
|
||||
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
|
||||
spec!(
|
||||
"view.set",
|
||||
"Set view",
|
||||
"{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size), bottomInset? (height of the strip the bottom toolbar covers)}",
|
||||
false,
|
||||
|s, p| {
|
||||
let v = view(s)?;
|
||||
if let Some(z) = num(p, "zoom") {
|
||||
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
|
||||
}
|
||||
v.cx = num_or(p, "cx", v.cx);
|
||||
v.cy = num_or(p, "cy", v.cy);
|
||||
if let Some(w) = num(p, "vw") {
|
||||
v.vw = w.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(h) = num(p, "vh") {
|
||||
v.vh = h.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(i) = num(p, "bottomInset") {
|
||||
v.bottom_inset = i.clamp(0.0, 20000.0);
|
||||
}
|
||||
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
|
||||
}
|
||||
v.cx = num_or(p, "cx", v.cx);
|
||||
v.cy = num_or(p, "cy", v.cy);
|
||||
if let Some(w) = num(p, "vw") {
|
||||
v.vw = w.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(h) = num(p, "vh") {
|
||||
v.vh = h.clamp(10.0, 20000.0);
|
||||
}
|
||||
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
|
||||
}),
|
||||
),
|
||||
spec!(
|
||||
"view.zoomAt",
|
||||
"Zoom tool",
|
||||
|
||||
@@ -131,7 +131,7 @@ fn zoom_to_fit_selection_and_frames() {
|
||||
// (command, selection before, zoom, centre, selection after, derivation)
|
||||
let rows: Vec<(&str, Vec<NodeId>, f64, (f64, f64), Vec<NodeId>, &str)> = vec![
|
||||
("view.zoomFit", vec![], fit(content), (470.0, 300.0), vec![], "Shift+1 fits everything on the page into the view"),
|
||||
("view.zoomSelection", vec![r], fit(Rect::new(0.0, 0.0, 40.0, 40.0)), (920.0, 20.0), vec![r], "Shift+2 zooms to the selection"),
|
||||
("view.zoomSelection", vec![r], (800.0 - 80.0) / 40.0, (920.0, 20.0), vec![r], "Shift+2 zooms to the selection, with a 40-point margin"),
|
||||
(
|
||||
"view.nextFrame",
|
||||
vec![],
|
||||
@@ -225,7 +225,7 @@ fn view_shortcuts_follow_the_documented_keys() {
|
||||
(Platform::Other, "-", m(true, false, false, false), "view.zoomOut", "Shift and - zooms out"),
|
||||
(Platform::Mac, "0", m(true, false, false, false), "view.zoom100", "Shift+0 zooms to 100%"),
|
||||
(Platform::Other, "1", m(true, false, false, false), "view.zoomFit", "Shift+1 zooms to fit"),
|
||||
(Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection"),
|
||||
(Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection, with a 40-point margin"),
|
||||
(Platform::Mac, "N", m(false, false, false, false), "view.nextFrame", "N zooms to the next frame"),
|
||||
(Platform::Other, "N", m(true, false, false, false), "view.prevFrame", "Shift+N zooms to the previous frame"),
|
||||
(Platform::Mac, "R", m(true, false, false, false), "view.rulers", "Shift+R shows and hides the rulers"),
|
||||
@@ -771,3 +771,151 @@ fn view_toggles_are_kept_for_the_next_launch() {
|
||||
}
|
||||
assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}");
|
||||
}
|
||||
|
||||
/// Select a fresh rectangle at the origin, set the view and run `cmd`; the rect's screen box is
|
||||
/// returned as (left, top, right, bottom).
|
||||
fn zoom_on(cmd: &str, inset: f64, w: f64, h: f64) -> (ViewState, (f64, f64, f64, f64)) {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, w, h, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 903, "bottomInset": inset}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, cmd, json!({}));
|
||||
let v = view(&s);
|
||||
let (l, t) = v.page_to_screen(0.0, 0.0);
|
||||
let (rr, b) = v.page_to_screen(w, h);
|
||||
(v, (l, t, rr, b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_leaves_forty_points_and_clears_the_toolbar() {
|
||||
// Measured in Figma's web app, 2026-11-09: 40 points on every side of the canvas above the toolbar.
|
||||
let (v, (l, t, r, b)) = zoom_on("view.zoomSelection", 58.0, 100.0, 100.0);
|
||||
assert!((v.zoom - 7.65).abs() < 0.01, "{}", v.zoom);
|
||||
assert!((t - 40.0).abs() < 0.5 && (b - (845.0 - 40.0)).abs() < 0.5, "top {t}, bottom {b}");
|
||||
assert!((l - (1389.0 - r)).abs() < 0.5, "left {l}, right {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_on_a_wide_layer_is_limited_by_the_width() {
|
||||
let (v, (l, _, r, _)) = zoom_on("view.zoomSelection", 58.0, 1000.0, 200.0);
|
||||
assert!((v.zoom - (1389.0 - 80.0) / 1000.0).abs() < 0.001, "{}", v.zoom);
|
||||
assert!((l - 40.0).abs() < 0.5 && ((1389.0 - r) - 40.0).abs() < 0.5, "left {l}, right {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_without_a_toolbar_inset_uses_the_whole_height() {
|
||||
let (v, _) = zoom_on("view.zoomSelection", 0.0, 100.0, 100.0);
|
||||
assert!((v.zoom - (903.0 - 80.0) / 100.0).abs() < 1e-6, "{}", v.zoom);
|
||||
let (_, top) = v.page_to_screen(0.0, 0.0);
|
||||
let (_, bottom) = v.page_to_screen(100.0, 100.0);
|
||||
assert!((top - 40.0).abs() < 1e-6 && (bottom - 863.0).abs() < 1e-6, "top {top}, bottom {bottom}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_in_a_short_canvas_still_ends_above_the_toolbar() {
|
||||
// A canvas 130 points high with the 60-point toolbar strip leaves 70 above it: too short for
|
||||
// 40 points a side, so the margin shrinks and the selection stays clear of the toolbar.
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 130, "bottomInset": 60}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
let (_, top) = v.page_to_screen(0.0, 0.0);
|
||||
let (_, bottom) = v.page_to_screen(100.0, 100.0);
|
||||
assert!(top >= 0.0 && bottom <= 70.0 + 1e-6, "top {top}, bottom {bottom}");
|
||||
assert!((top - (70.0 - bottom)).abs() < 1e-6, "centred above the toolbar: top {top}, bottom {bottom}");
|
||||
// The margin shrinks to a quarter of the 70 points (17.5) and the zoom is the exact fit, not the floor.
|
||||
assert!((v.zoom - 0.35).abs() < 1e-9, "{}", v.zoom);
|
||||
assert!((top - 17.5).abs() < 1e-6 && (bottom - 52.5).abs() < 1e-6, "top {top}, bottom {bottom}");
|
||||
}
|
||||
|
||||
/// A canvas narrower than 160 points keeps a quarter of its width on each side.
|
||||
#[test]
|
||||
fn zoom_to_selection_in_a_narrow_canvas_shrinks_the_side_margins() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 100, "vh": 903, "bottomInset": 0}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
let (left, _) = v.page_to_screen(0.0, 0.0);
|
||||
let (right, _) = v.page_to_screen(100.0, 100.0);
|
||||
assert!((v.zoom - 0.5).abs() < 1e-9, "{}", v.zoom);
|
||||
assert!((left - 25.0).abs() < 1e-6 && (right - 75.0).abs() < 1e-6, "left {left}, right {right}");
|
||||
}
|
||||
|
||||
/// A view 1000 by 800 with a 100-point toolbar strip, for the edge cases of the fit.
|
||||
fn strip_view() -> ViewState {
|
||||
ViewState { vw: 1000.0, vh: 800.0, bottom_inset: 100.0, ..ViewState::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_a_selection_survives_points_lines_extremes_and_junk() {
|
||||
// A point: the zoom stays, and it lands in the middle of the area above the toolbar (y = 350).
|
||||
let mut v = strip_view();
|
||||
v.zoom = 2.0;
|
||||
v.fit_selection(Rect::new(10.0, 20.0, 10.0, 20.0), 32.0);
|
||||
assert_eq!(v.zoom, 2.0);
|
||||
let (x, y) = v.page_to_screen(10.0, 20.0);
|
||||
assert!((x - 500.0).abs() < 1e-9 && (y - 350.0).abs() < 1e-9, "{x}, {y}");
|
||||
// A zero-width line is limited by its height, a zero-height one by its width.
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 0.0, 100.0), 32.0);
|
||||
assert!((v.zoom - (700.0 - 80.0) / 100.0).abs() < 1e-9, "{}", v.zoom);
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 100.0, 0.0), 32.0);
|
||||
assert!((v.zoom - (1000.0 - 80.0) / 100.0).abs() < 1e-9, "{}", v.zoom);
|
||||
// A tiny one stops at the maximum zoom, a huge one at the minimum.
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 0.001, 0.001), 32.0);
|
||||
assert_eq!(v.zoom, 32.0);
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 1e12, 1e12), 32.0);
|
||||
assert_eq!(v.zoom, ViewState::MIN_ZOOM);
|
||||
// A rectangle with any coordinate that is not a finite number changes nothing.
|
||||
for bad in [
|
||||
Rect::new(0.0, f64::NAN, 10.0, 10.0),
|
||||
Rect::new(0.0, 0.0, f64::INFINITY, 10.0),
|
||||
Rect::new(0.0, 0.0, 10.0, f64::NAN),
|
||||
Rect::new(f64::NAN, 0.0, 10.0, 10.0),
|
||||
] {
|
||||
let mut v = strip_view();
|
||||
let before = (v.zoom, v.cx, v.cy);
|
||||
v.fit_selection(bad, 32.0);
|
||||
assert_eq!((v.zoom, v.cx, v.cy), before, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bottom_inset_is_clamped_and_never_saved() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "view.set", json!({"bottomInset": -5}));
|
||||
assert_eq!(view(&s).bottom_inset, 0.0);
|
||||
ex(&mut s, "view.set", json!({"bottomInset": 1e12}));
|
||||
assert_eq!(view(&s).bottom_inset, 20000.0);
|
||||
// Larger than the viewport: the fit still leaves a sliver of canvas and a finite centre.
|
||||
ex(&mut s, "view.set", json!({"vw": 400, "vh": 300, "bottomInset": 5000}));
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
assert!(v.zoom.is_finite() && v.cx.is_finite() && v.cy.is_finite(), "{v:?}");
|
||||
// It is not part of what the view saves.
|
||||
let saved = serde_json::to_string(&v).unwrap();
|
||||
assert!(!saved.to_lowercase().contains("inset"), "{saved}");
|
||||
let back: ViewState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(back.bottom_inset, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fit_is_unchanged() {
|
||||
// Left alone on purpose: its 64-point margin is not measured against Figma yet.
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 903, "bottomInset": 58}));
|
||||
ex(&mut s, "view.zoomFit", json!({}));
|
||||
let v = view(&s);
|
||||
assert!((v.zoom - (903.0 - 128.0) / 100.0).abs() < 1e-6);
|
||||
// No toolbar offset: the rectangle's own centre stays in the middle of the whole viewport.
|
||||
assert_eq!((v.cx, v.cy), (50.0, 50.0));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ pub struct ViewState {
|
||||
/// Viewport size in logical pixels (set by the UI; used by zoom-to-fit).
|
||||
pub vw: f64,
|
||||
pub vh: f64,
|
||||
/// Height in screen points of the strip the floating bottom toolbar covers (from its top edge to
|
||||
/// the canvas's bottom edge); set by the UI every frame, 0 when it's hidden. Never saved.
|
||||
#[serde(skip)]
|
||||
pub bottom_inset: f64,
|
||||
pub pixel_grid: bool,
|
||||
pub layout_grids: bool,
|
||||
pub rulers: bool,
|
||||
@@ -51,6 +55,7 @@ impl Default for ViewState {
|
||||
zoom: 1.0,
|
||||
vw: 1200.0,
|
||||
vh: 800.0,
|
||||
bottom_inset: 0.0,
|
||||
pixel_grid: true,
|
||||
layout_grids: true,
|
||||
rulers: false,
|
||||
@@ -105,6 +110,32 @@ impl ViewState {
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y;
|
||||
}
|
||||
/// Fit a page rectangle for Zoom to selection: a 40 point margin on all four sides of the area
|
||||
/// above the bottom toolbar (`vw` by `vh - bottom_inset`), centred in that area.
|
||||
pub fn fit_selection(&mut self, r: figcraft_geom::Rect, max_zoom: f64) {
|
||||
// A rectangle that isn't all finite numbers leaves the view as it is.
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let inset = if self.bottom_inset.is_finite() { self.bottom_inset.clamp(0.0, (self.vh - 1.0).max(0.0)) } else { 0.0 };
|
||||
let area_h = (self.vh - inset).max(1.0);
|
||||
// A point (nothing to fit) is centred in the area above the toolbar at the zoom it has.
|
||||
if !(r.width() > 0.0 || r.height() > 0.0) {
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom.max(1e-6);
|
||||
return;
|
||||
}
|
||||
// 40 points as measured; a canvas too small for that keeps a quarter of its size on each
|
||||
// side instead, so the selection still ends above the toolbar.
|
||||
let mx = (self.vw / 4.0).clamp(0.0, 40.0);
|
||||
let my = (area_h / 4.0).clamp(0.0, 40.0);
|
||||
let zx = (self.vw - mx * 2.0).max(1.0) / r.width().max(1e-6);
|
||||
let zy = (area_h - my * 2.0).max(1.0) / r.height().max(1e-6);
|
||||
self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom);
|
||||
// The rect's centre lands at screen y = area_h / 2, the page centre `(vh - area_h) / 2` below it.
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom;
|
||||
}
|
||||
/// The zoom from which the pixel grid shows (400%).
|
||||
pub const PIXEL_GRID_ZOOM: f64 = 4.0;
|
||||
/// Whether the pixel grid draws: it's on and the canvas is zoomed in to 400% or more.
|
||||
|
||||
@@ -379,6 +379,12 @@ impl FigApp {
|
||||
let worker = self.worker.take();
|
||||
let mut worker = worker.unwrap_or_else(|| render_worker::Worker::new(ctx.clone()));
|
||||
canvas::show(self, ui, &mut worker);
|
||||
// The strip the floating bottom toolbar covers, for Zoom to selection (0 when it's hidden).
|
||||
let inset =
|
||||
if !hidden && mobile.is_none() { f64::from(self.canvas.rect.max.y - toolbar::pill_rect(self.canvas.rect, 0.0).min.y) } else { 0.0 };
|
||||
if let Ok(ds) = self.session.ds_mut() {
|
||||
ds.view.bottom_inset = inset;
|
||||
}
|
||||
self.worker = Some(worker);
|
||||
if !hidden && mobile.is_none() {
|
||||
ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| {
|
||||
|
||||
@@ -90,7 +90,14 @@ fn shift_digits_zoom() {
|
||||
press(&mut u, Key::Num2, Modifiers::SHIFT);
|
||||
assert!(zoom(&u) > fit, "Shift+2 fits the smaller selection");
|
||||
let v = &u.app.session.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (110.0, 40.0), "centred on it");
|
||||
// Centred in the canvas above the bottom toolbar: the page centre sits half the toolbar's strip lower.
|
||||
// The strip is the canvas's bottom edge less the pill's top, worked out here, not read back.
|
||||
let strip = f64::from(u.app.canvas.rect.max.y - crate::toolbar::pill_rect(u.app.canvas.rect, 0.0).min.y);
|
||||
assert!(strip > 20.0 && strip < 200.0, "the pill is a toolbar's height: {strip}");
|
||||
assert!((v.bottom_inset - strip).abs() < 1e-3, "the app gives the engine the pill's strip: {} vs {strip}", v.bottom_inset);
|
||||
let expect_cy = 40.0 + strip / 2.0 / v.zoom;
|
||||
assert_eq!(v.cx, 110.0, "centred on it");
|
||||
assert!((v.cy - expect_cy).abs() < 1e-9, "centred above the toolbar: {} vs {expect_cy}", v.cy);
|
||||
// `+` and `-` step the zoom.
|
||||
let z = zoom(&u);
|
||||
press(&mut u, Key::Minus, Modifiers::NONE);
|
||||
@@ -201,3 +208,16 @@ fn shortcuts_panel_does_not_show_on_the_home_screen() {
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.shortcuts_panel, "the panel closes on the home screen");
|
||||
}
|
||||
|
||||
/// The strip the bottom toolbar covers is given to the engine while the toolbar shows and is zero
|
||||
/// when the interface is hidden.
|
||||
#[test]
|
||||
fn the_toolbar_strip_is_zero_when_the_interface_is_hidden() {
|
||||
let (mut u, _, _) = setup();
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.session.ds().unwrap().view.bottom_inset > 0.0, "shown");
|
||||
u.app.session.ds_mut().unwrap().view.ui_hidden = true;
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.ds().unwrap().view.bottom_inset, 0.0, "hidden");
|
||||
}
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user