Merge main into #2147

This commit is contained in:
bflatastic committed 2026-11-12 11:23:43 +07:00
commit 68b261a76b
5 files changed
+58 -11

No files matched your search

+49 -4
View File
@@ -100,12 +100,16 @@ fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
Ok(json!({"zoom": v.zoom}))
}
/// How far an arrow key pans the view with nothing selected (screen pixels; Shift: ten times).
pub const PAN_STEP_PX: f64 = 20.0;
/// How far an arrow key pans the view with nothing selected (screen pixels). Measured in Figma at
/// 100% zoom: about 64 px (62-64 in two trials, +/-3 px).
pub const PAN_STEP_PX: f64 = 64.0;
/// Shift multiplies the arrow pan by this much (Figma: about 320 px, five steps).
pub const PAN_BIG_STEPS: f64 = 5.0;
/// `view.pan`: the arrow keys with nothing selected move the view by keyboard steps.
fn pan(s: &mut Session, p: &Value) -> Result<Value> {
let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * 10.0 } else { PAN_STEP_PX };
let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * PAN_BIG_STEPS } else { PAN_STEP_PX };
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
let (dx, dy) = (dir("dx"), dir("dy"));
let v = view(s)?;
@@ -318,7 +322,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
toggle(s, p, "view.multiplayerCursors")
}),
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (five times as far, like Shift)}", false, pan),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
toggle(s, p, "view.outlinesSelection")
@@ -705,4 +709,45 @@ mod start_on_home_tests {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.start_on_home);
}
#[test]
fn the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma() {
let mut s = crate::Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("view.pan", &json!({"dx": 1})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((v.cx, v.cy), (364.0, 200.0), "one press pans 64 px");
s.execute("view.pan", &json!({"dy": -1, "big": true})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((v.cx, v.cy), (364.0, -120.0), "Shift pans 320 px");
}
#[test]
fn the_arrow_pan_is_the_same_screen_distance_at_any_zoom_on_both_axes() {
// (zoom, dx, dy, big, expected centre change in document px)
let cases = [
(2.0, 1.0, 0.0, false, 32.0, 0.0),
(0.5, 1.0, 0.0, false, 128.0, 0.0),
(2.0, 0.0, 1.0, true, 0.0, 160.0),
(0.5, 0.0, 1.0, true, 0.0, 640.0),
(1.0, -1.0, 0.0, false, -64.0, 0.0),
(1.0, 1.0, 0.0, true, 320.0, 0.0),
(1.0, -1.0, 0.0, true, -320.0, 0.0),
(1.0, 0.0, -1.0, false, 0.0, -64.0),
(1.0, 0.0, 1.0, false, 0.0, 64.0),
(1.0, 0.0, -1.0, true, 0.0, -320.0),
];
for (zoom, dx, dy, big, ex, ey) in cases {
let mut s = crate::Session::new();
s.execute("view.set", &json!({"zoom": zoom, "cx": 300, "cy": 200})).unwrap();
s.execute("view.pan", &json!({"dx": dx, "dy": dy, "big": big})).unwrap();
let v = &s.ds().unwrap().view;
let got = (v.cx - 300.0, v.cy - 200.0);
assert!(
(got.0 - ex).abs() < 1e-6 && (got.1 - ey).abs() < 1e-6,
"zoom {zoom} dx {dx} dy {dy} big {big}: moved {got:?}, expected {:?}",
(ex, ey)
);
}
}
}
+3 -3
View File
@@ -62,12 +62,12 @@ fn control_key_arrows_pan_with_nothing_selected() {
(v.cx, v.cy)
};
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
assert_eq!(view(&u), (10.0, 0.0), "20 screen pixels at 200%");
assert_eq!(view(&u), (32.0, 0.0), "64 screen pixels at 200% are 32 document pixels");
call(&mut u, "ui.key", json!({"key": "ArrowUp", "shift": true})).unwrap();
assert_eq!(view(&u), (10.0, -100.0), "Shift: ten times as far");
assert_eq!(view(&u), (32.0, -160.0), "Shift: 320 screen pixels, 160 document pixels");
u.app.run("select.set", json!({"ids": [r]}));
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
assert_eq!(view(&u), (10.0, -100.0), "with a selection the view stays");
assert_eq!(view(&u), (32.0, -160.0), "with a selection the view stays");
let id = figcraft_engine::params::id_of(&r).unwrap();
assert_eq!(u.app.session.doc().unwrap().world_bounds(id).unwrap().x0, 1.0, "the selection moved instead");
// With a shape tool chosen and nothing selected the arrows move the placement crosshair
+3 -3
View File
@@ -44,13 +44,13 @@ fn arrows_pan_the_view_when_nothing_is_selected() {
u.app.run("select.set", json!({"ids": []}));
u.app.run("view.set", json!({"zoom": 2.0, "cx": 0, "cy": 0}));
press(&mut u, Key::ArrowRight, Modifiers::NONE);
assert_eq!(view(&u), (10.0, 0.0, 2.0), "20 screen pixels at 200%");
assert_eq!(view(&u), (32.0, 0.0, 2.0), "64 screen pixels at 200% are 32 document pixels");
press(&mut u, Key::ArrowUp, Modifiers::SHIFT);
assert_eq!(view(&u), (10.0, -100.0, 2.0), "Shift: ten times as far");
assert_eq!(view(&u), (32.0, -160.0, 2.0), "Shift: 320 screen pixels, 160 document pixels");
// With a selection the arrows nudge it and the view stays.
u.app.run("select.set", json!({"ids": [r]}));
press(&mut u, Key::ArrowRight, Modifiers::NONE);
assert_eq!(view(&u), (10.0, -100.0, 2.0));
assert_eq!(view(&u), (32.0, -160.0, 2.0));
assert_eq!(u.app.session.doc().unwrap().world_bounds(r).unwrap().x0, 1.0);
}
+1 -1
View File
@@ -644,7 +644,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
`board.nextSticky` (`Shift+Cmd/Ctrl+Enter`, also while typing in a sticky). Keys no design-file
shortcut uses; on a design file they refuse.
- `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same)
pan the view by 20 screen pixels a step, 200 with `big` (Shift).
pan the view by 64 screen pixels a step, 320 with `big` (Shift).
- `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session.
- `view.outlinesHiddenLayers {on?}` and `view.outlinesObjectBounds {on?}`: what outline mode
(`view.outlines`, `view.outlinesSelection`) draws besides the shapes: hidden layers, and the box
+2
View File
@@ -112,6 +112,7 @@ interaction feel not yet compared.
| Item | Status | Notes |
|---|---|---|
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); Preferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only |
| Frame labels (rotated, right-to-left), size badge | done | |
| Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on |
@@ -190,6 +191,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. |
| 2026-11-07 | patch | View: the arrow keys with nothing selected pan 64 px, 320 with Shift (five steps), as measured in Figma's web app at 100% zoom (62–64 px in two trials, about 320 px with Shift, about ±3 px); they panned 20 px (200 with Shift). Test: `cmd::view::start_on_home_tests::the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma` |
| 2026-11-09 | minor | Shortcuts: Shift+T picks the new Annotation tool (`tool.annotation {id?}`, Accessibility at Figma, Add an annotation): with a layer selected, or an `id`, it asks the UI for that layer's note at once; in the tool a press on a layer selects it and asks, a press on empty canvas does nothing; the Inspect tab opens with the caret in the new-note field and the engine writes nothing itself; documented shortcuts 137, bound 136 (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| 2026-11-11 | patch | M5, gap 4: marquee selection as measured in Figma's web app (Select layers and objects, Selection marquee): a partly covered top-level frame or section gives the layers in it the marquee touches (it was selected only when enclosed, so nothing came), an empty frame is selected on touch, a rotated shape is touched by its outline and not its box, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole), and also takes the top-level layers it touches outside it, and selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole, not its layers). Flipped: `marquee_selects_what_it_touches`, `cmd_marquee_selects_nested_layers`, `a_box_selects_like_a_marquee`. Tests: `marquee_behaviour_tests`. Counts unchanged |
| 2026-11-08 | minor | Vector edit mode, #1875, measured in Figma (web, real keys): Copy with segments selected takes only those (with just a point selected, the segments touching it) and a paste outside edit mode is a new vector layer in place; Cut removes them, the path splitting into subpaths in the same layer with edit mode staying on, and a paste outside gives a new layer with the cut segments; Paste while editing a vector adds the clipboard's segments, or a copied layer's outline, to the same network in place, selecting the pasted points, with no new layer and the bounds growing. FigCraft copied the whole layer, Cut left nothing to paste and Paste did nothing. `edit.copy`, `edit.cut` and `edit.paste` act so with no `ids`/`parent` given (Ctrl+X is the real Cut again; Shift+Delete still heals); the copy carries the layer's look (fills, strokes, effects, opacity, blend; the width profile only when every segment is copied); layers with no outline are skipped. Tests: `vector_clipboard_tests` |