mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
Merge main into #2319
This commit is contained in:
commit
c768dd7e4e
10 files changed
+212
-34
No files matched your search
+39
-16
@@ -152,7 +152,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
|
||||
/// boolean groups are hit where their `result` is.
|
||||
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
let p = doc.node(page)?;
|
||||
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None).filter(|_| !masked_out(doc, p, *c, pt)))
|
||||
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None, HitOpts::default()).filter(|_| !masked_out(doc, p, *c, pt)))
|
||||
}
|
||||
|
||||
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
|
||||
@@ -175,7 +175,7 @@ fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool
|
||||
|
||||
/// Like [`hit_leaf`], but only within layer `root` (it included).
|
||||
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
hit_rec(doc, root, pt, tol, 0, result, None)
|
||||
hit_rec(doc, root, pt, tol, 0, result, None, HitOpts::default())
|
||||
}
|
||||
|
||||
/// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit
|
||||
@@ -185,8 +185,8 @@ pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolRes
|
||||
/// when it shows them, and every layer on the edge of its box when it shows object bounds.
|
||||
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult, also: OutlineHits) -> Option<NodeId> {
|
||||
match doc.node(root) {
|
||||
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also))),
|
||||
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also)),
|
||||
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also), HitOpts::default())),
|
||||
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also), HitOpts::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,13 +204,30 @@ fn on_box_edge(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> boo
|
||||
r.inflate(tol, tol).contains(lp) && !(r.width() > 2.0 * tol && r.height() > 2.0 * tol && r.inflate(-tol, -tol).contains(lp))
|
||||
}
|
||||
|
||||
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, also: Option<OutlineHits>) -> Option<NodeId> {
|
||||
/// Options for the hit tests that the plain pick never sets.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HitOpts {
|
||||
/// Reach locked layers (and what a locked frame holds) too. A click, hover or marquee never
|
||||
/// does; the Select layer list does.
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
fn hit_rec(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
pt: Point,
|
||||
tol: f64,
|
||||
depth: usize,
|
||||
result: BoolResult,
|
||||
also: Option<OutlineHits>,
|
||||
opts: HitOpts,
|
||||
) -> Option<NodeId> {
|
||||
if depth > 512 {
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(id)?;
|
||||
let outline = also.is_some();
|
||||
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked {
|
||||
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked && !opts.locked {
|
||||
return None;
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden: true } = n.kind {
|
||||
@@ -228,7 +245,7 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
|
||||
return None;
|
||||
}
|
||||
for c in n.paint_order().rev() {
|
||||
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also).filter(|_| !masked_out(doc, n, c, pt)) {
|
||||
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also, opts).filter(|_| !masked_out(doc, n, c, pt)) {
|
||||
return Some(h);
|
||||
}
|
||||
}
|
||||
@@ -239,14 +256,20 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
|
||||
None
|
||||
}
|
||||
|
||||
/// Every visible, unlocked layer under `pt` (page coordinates) with its depth below the page, in
|
||||
/// layer-tree order (each layer before what it contains, the front-most first): what
|
||||
/// "Select layer" in the context menu lists. Clip content is honoured; boolean groups (hit where
|
||||
/// their `result` is) and sections with hidden contents count as one layer.
|
||||
/// Every visible, unlocked layer under `pt`: [`layers_at_opts`] with the default options.
|
||||
pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Vec<(NodeId, usize)> {
|
||||
fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, out: &mut Vec<(NodeId, usize)>) {
|
||||
layers_at_opts(doc, page, pt, tol, result, HitOpts::default())
|
||||
}
|
||||
|
||||
/// Every visible layer under `pt` (page coordinates) with its depth below the page, in
|
||||
/// layer-tree order (each layer before what it contains, the front-most first): what
|
||||
/// "Select layer" in the context menu lists. Locked layers are left out unless `opts.locked`.
|
||||
/// Clip content is honoured; boolean groups (hit where their `result` is) and sections with
|
||||
/// hidden contents count as one layer.
|
||||
pub fn layers_at_opts(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult, opts: HitOpts) -> Vec<(NodeId, usize)> {
|
||||
fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, opts: HitOpts, out: &mut Vec<(NodeId, usize)>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
if !n.visible || n.locked || depth > 512 {
|
||||
if !n.visible || n.locked && !opts.locked || depth > 512 {
|
||||
return;
|
||||
}
|
||||
let clips = n.clip && n.kind.is_frame_like();
|
||||
@@ -256,14 +279,14 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
|
||||
let at = out.len();
|
||||
let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true });
|
||||
if whole {
|
||||
if hit_rec(doc, id, pt, tol, depth, result, None).is_some() {
|
||||
if hit_rec(doc, id, pt, tol, depth, result, None, opts).is_some() {
|
||||
out.push((id, depth));
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in n.paint_order().rev() {
|
||||
let before = out.len();
|
||||
rec(doc, c, pt, tol, depth + 1, result, out);
|
||||
rec(doc, c, pt, tol, depth + 1, result, opts, out);
|
||||
if out.len() > before && masked_out(doc, n, c, pt) {
|
||||
out.truncate(before);
|
||||
}
|
||||
@@ -276,7 +299,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
|
||||
if let Some(p) = doc.node(page) {
|
||||
for c in p.children.iter().rev() {
|
||||
let before = out.len();
|
||||
rec(doc, *c, pt, tol, 0, result, &mut out);
|
||||
rec(doc, *c, pt, tol, 0, result, opts, &mut out);
|
||||
if out.len() > before && masked_out(doc, p, *c, pt) {
|
||||
out.truncate(before);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"select.layersAt",
|
||||
"Layers at a point",
|
||||
"{x, y (page coordinates)} → {layers: [{id, name, type, depth}]} (every layer under the point, front-most first; what the context menu's Select layer lists)",
|
||||
"{x, y (page coordinates)} → {layers: [{id, name, type, depth, locked}]} (every layer under the point, locked ones too, front-most first; locked is true when the layer or an ancestor is locked; what the context menu's Select layer lists)",
|
||||
false,
|
||||
layers_at
|
||||
),
|
||||
|
||||
@@ -8,9 +8,13 @@ pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let zoom = s.ds()?.view.zoom.max(1e-6);
|
||||
let sel = s.selection();
|
||||
let result = crate::booleans::result_outline(doc, &sel);
|
||||
let layers: Vec<Value> = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result))
|
||||
let opts = figcraft_doc::geometry::HitOpts { locked: true };
|
||||
let layers: Vec<Value> = figcraft_doc::geometry::layers_at_opts(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result), opts)
|
||||
.into_iter()
|
||||
.filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth})))
|
||||
.filter_map(|(id, depth)| {
|
||||
doc.node(id)
|
||||
.map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth, "locked": crate::tools::locked_in(doc, id)}))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"layers": layers}))
|
||||
}
|
||||
|
||||
@@ -118,8 +118,33 @@ fn layers_at_a_point_list_the_stack_front_most_first() {
|
||||
let got: Vec<(NodeId, u64)> =
|
||||
out["layers"].as_array().unwrap().iter().map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap())).collect();
|
||||
assert_eq!(got, vec![(f, 0), (over, 1), (g, 1), (a, 2)]);
|
||||
assert!(out["layers"].as_array().unwrap().iter().all(|l| l["locked"] == json!(false)), "none is locked");
|
||||
assert_eq!(out["layers"][0]["type"], json!("FRAME"));
|
||||
// Nothing under empty canvas; junk is refused.
|
||||
assert_eq!(ex(&mut s, "select.layersAt", json!({"x": 2000, "y": 2000}))["layers"], json!([]));
|
||||
assert!(s.execute("select.layersAt", &json!({"x": "left"})).is_err());
|
||||
}
|
||||
|
||||
/// Measured in Figma's web app, 2026-11-10 (Select layer lists a locked layer, and picking it selects it).
|
||||
#[test]
|
||||
fn layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them() {
|
||||
let mut s = Session::new();
|
||||
let (f, g, [a, ..], _) = scene(&mut s);
|
||||
let over = rect_in(&mut s, 10.0, 10.0, Some(f));
|
||||
let lk = rect_in(&mut s, 10.0, 10.0, Some(f));
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [lk], "locked": true}));
|
||||
let out = ex(&mut s, "select.layersAt", json!({"x": 30, "y": 30}));
|
||||
let got: Vec<(NodeId, u64, bool)> = out["layers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap(), l["locked"].as_bool().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(got, vec![(f, 0, false), (lk, 1, true), (over, 1, false), (g, 1, false), (a, 2, false)]);
|
||||
ex(&mut s, "select.set", json!({"ids": [lk]}));
|
||||
assert_eq!(s.selection(), vec![lk]);
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
s.pointer(&ev(PointerKind::Down, 30.0, 30.0, false, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 30.0, 30.0, false, false)).unwrap();
|
||||
assert_eq!(s.selection(), vec![over], "a click still skips the locked layer");
|
||||
}
|
||||
@@ -995,6 +995,18 @@ pub fn disabled(resp: &Response) {
|
||||
node(resp, "", |n| n.set_disabled());
|
||||
}
|
||||
|
||||
/// `resp`'s node also says `text` (a menu row's state, e.g. "locked"), after any description
|
||||
/// already set (joined with ", ").
|
||||
pub fn describe(resp: &Response, text: &str) {
|
||||
node(resp, "", |n| {
|
||||
let d = match n.description() {
|
||||
Some(old) if !old.is_empty() => format!("{old}, {text}"),
|
||||
_ => text.to_string(),
|
||||
};
|
||||
n.set_description(d);
|
||||
});
|
||||
}
|
||||
|
||||
/// What a layer row says besides its name.
|
||||
pub struct RowInfo<'a> {
|
||||
/// The layer's type ("Frame", "Text").
|
||||
|
||||
@@ -127,3 +127,102 @@ fn copying_a_link_from_a_frames_name_label_links_that_frame() {
|
||||
crate::menus::invoke(&mut u.app, "edit.copyLink");
|
||||
assert_eq!(copied.lock().unwrap().last().cloned(), Some(format!("figcraft://node/{}", a.0)));
|
||||
}
|
||||
|
||||
/// Two overlapping rectangles, the upper one locked; the menu opens on the overlap with nothing
|
||||
/// selected. Returns the app's UI and the ids (lower, locked).
|
||||
fn overlap_ui() -> (Ui, figcraft_doc::NodeId, figcraft_doc::NodeId) {
|
||||
let mut s = Session::new();
|
||||
let lower = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 80, "height": 80, "name": "Lower"})).unwrap());
|
||||
let upper = id(&s.execute("shape.rectangle", &json!({"x": 120, "y": 120, "width": 80, "height": 80, "name": "Upper"})).unwrap());
|
||||
s.execute("object.toggleLock", &json!({"ids": [upper], "locked": true})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
(u, lower, upper)
|
||||
}
|
||||
|
||||
/// Measured in Figma's web app, 2026-11-10 (Lock and unlock layers: locked layers are listed in Select layer).
|
||||
#[test]
|
||||
fn select_layer_marks_locked_layers_with_a_padlock_and_picks_them() {
|
||||
let (mut u, lower, upper) = overlap_ui();
|
||||
let at = u.app.to_screen(150.0, 150.0);
|
||||
crate::canvas::open_context_menu(&mut u.app, at, false);
|
||||
u.app.session.set_selection(vec![]);
|
||||
crate::menus::open_select_layer(&u.ctx, true);
|
||||
u.frame(vec![]);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
// The Layers panel lists the same names; the menu's rows are the right-most copies.
|
||||
let menu_row = |name: &str| {
|
||||
let (_, pos, size) = crate::live_tests::texts(&shapes)
|
||||
.into_iter()
|
||||
.filter(|(t, _, _)| t == name)
|
||||
.max_by(|a, b| a.1.x.total_cmp(&b.1.x))
|
||||
.unwrap_or_else(|| panic!("no row {name:?}"));
|
||||
(pos, size)
|
||||
};
|
||||
let (lower_at, upper_at) = (menu_row("Lower"), menu_row("Upper"));
|
||||
assert!(lower_at.0.x > 300.0 && upper_at.0.x > 300.0, "both layers are listed in the menu");
|
||||
let in_leading = |(pos, size): (Pos2, egui::Vec2)| {
|
||||
let c = pos + size / 2.0;
|
||||
shapes
|
||||
.iter()
|
||||
.filter(|s| !matches!(s, egui::Shape::Text(_)))
|
||||
.filter(|s| {
|
||||
let r = s.visual_bounding_rect();
|
||||
r.is_positive()
|
||||
&& (r.center().y - c.y).abs() < 8.0
|
||||
&& r.max.x < c.x - size.x / 2.0 - 4.0
|
||||
&& r.min.x > c.x - size.x / 2.0 - 40.0
|
||||
&& r.width() < 20.0
|
||||
})
|
||||
.count()
|
||||
};
|
||||
assert!(in_leading(upper_at) > 0, "the locked row has a padlock in its leading column");
|
||||
assert_eq!(in_leading(lower_at), 0, "the other row has nothing there");
|
||||
let p = upper_at.0 + upper_at.1 / 2.0;
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![
|
||||
egui::Event::PointerMoved(p),
|
||||
egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
|
||||
]);
|
||||
}
|
||||
assert_eq!(u.app.session.selection(), vec![upper], "picking the locked row selects it");
|
||||
let _ = lower;
|
||||
}
|
||||
|
||||
/// The padlock is only paint: the locked row says "locked" to assistive technology too.
|
||||
#[test]
|
||||
fn select_layer_locked_row_is_described_as_locked() {
|
||||
let (mut u, _, _) = overlap_ui();
|
||||
let at = u.app.to_screen(150.0, 150.0);
|
||||
crate::canvas::open_context_menu(&mut u.app, at, false);
|
||||
u.app.session.set_selection(vec![]);
|
||||
crate::menus::open_select_layer(&u.ctx, true);
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
let nodes: Vec<egui::accesskit::Node> =
|
||||
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect();
|
||||
let row = |name: &str| {
|
||||
nodes
|
||||
.iter()
|
||||
.find(|n| n.role() == egui::accesskit::Role::MenuItem && n.label() == Some(name))
|
||||
.unwrap_or_else(|| panic!("no menu row {name:?}"))
|
||||
};
|
||||
assert!(row("Upper").description().is_some_and(|d| d.contains("locked")), "the locked row says locked");
|
||||
assert!(!row("Lower").description().is_some_and(|d| d.contains("locked")), "the other row doesn't");
|
||||
}
|
||||
|
||||
/// Measured on a single locked layer only (Figma's web app, 2026-11-10): the least certain rule.
|
||||
#[test]
|
||||
fn select_layer_needs_more_than_one_layer_under_the_pointer() {
|
||||
let (mut u, _, _) = overlap_ui();
|
||||
let one = u.app.to_screen(105.0, 105.0);
|
||||
crate::canvas::open_context_menu(&mut u.app, one, false);
|
||||
u.frame(vec![]);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(!has_text(&shapes, "Select layer"), "one layer under the pointer: no Select layer");
|
||||
let two = u.app.to_screen(150.0, 150.0);
|
||||
crate::canvas::open_context_menu(&mut u.app, two, false);
|
||||
u.frame(vec![]);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(has_text(&shapes, "Select layer"), "two layers: it shows");
|
||||
}
|
||||
@@ -15,7 +15,8 @@ pub fn open_select_layer(ctx: &egui::Context, open: bool) {
|
||||
}
|
||||
|
||||
/// The context menu's "Select layer" submenu (right-click on the canvas): every layer under the
|
||||
/// click, nested ones indented, the selected ones checked. Returns whether one was picked.
|
||||
/// click (locked ones with a padlock), nested ones indented, the selected ones checked; shown
|
||||
/// only with more than one layer under the pointer. Returns whether one was picked.
|
||||
pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) -> bool {
|
||||
if !app.canvas.rect.contains(pos) {
|
||||
return false;
|
||||
@@ -23,15 +24,21 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2,
|
||||
let p = app.to_page(pos);
|
||||
let Ok(ds) = app.session.ds() else { return false };
|
||||
let tol = 2.0 / ds.view.zoom.max(1e-6);
|
||||
let layers: Vec<(figcraft_doc::NodeId, usize, String)> =
|
||||
figcraft_doc::geometry::layers_at(&ds.doc, ds.page, p, tol, Some(&figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection)))
|
||||
let opts = figcraft_doc::geometry::HitOpts { locked: true };
|
||||
let layers: Vec<(figcraft_doc::NodeId, usize, String, bool)> = {
|
||||
let result = figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection);
|
||||
figcraft_doc::geometry::layers_at_opts(&ds.doc, ds.page, p, tol, Some(&result), opts)
|
||||
.into_iter()
|
||||
.take(40)
|
||||
.filter_map(|(id, depth)| {
|
||||
ds.doc.node(id).map(|n| (id, depth, if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() }))
|
||||
ds.doc.node(id).map(|n| {
|
||||
let name = if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() };
|
||||
(id, depth, name, figcraft_engine::tools::locked_in(&ds.doc, id))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if layers.is_empty() {
|
||||
.collect()
|
||||
};
|
||||
if layers.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
let sel = ds.selection.clone();
|
||||
@@ -40,8 +47,8 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2,
|
||||
SubmenuOptions { area: Id::unique("ctx_select_layer"), key: SELECT_LAYER, label: tr("Select layer"), gap: 4.0, constrain: true, tokens: t },
|
||||
|ui| {
|
||||
let mut picked = None;
|
||||
for (id, depth, name) in &layers {
|
||||
if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
|
||||
for (id, depth, name, locked) in &layers {
|
||||
if indented_item(ui, name, "", true, sel.contains(id), *locked, 12.0 * (*depth).min(8) as f32, t) {
|
||||
picked = Some(*id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,17 +395,22 @@ fn need_width(ui: &Ui, w: f32) {
|
||||
}
|
||||
|
||||
pub(crate) fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool {
|
||||
indented_item(ui, label, shortcut, enabled, checked, 0.0, t)
|
||||
indented_item(ui, label, shortcut, enabled, checked, false, 0.0, t)
|
||||
}
|
||||
|
||||
/// A menu row whose label starts `indent` pixels further in (nested layers).
|
||||
pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, indent: f32, t: &Tokens) -> bool {
|
||||
/// A menu row whose label starts `indent` pixels further in (nested layers); `locked` paints a
|
||||
/// padlock in the leading column (a checked row shows the check instead).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, locked: bool, indent: f32, t: &Tokens) -> bool {
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
|
||||
a11y::menu_item(&resp, label, shortcut, checked.then_some(true));
|
||||
if !enabled {
|
||||
a11y::disabled(&resp);
|
||||
}
|
||||
if locked {
|
||||
a11y::describe(&resp, i18n::tr("locked"));
|
||||
}
|
||||
// The keyboard's item is highlighted like the hovered one.
|
||||
a11y::own_focus(&resp);
|
||||
let hov = plain_row_hover(ui, &resp) && enabled;
|
||||
@@ -415,6 +420,8 @@ pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: b
|
||||
let col = if enabled { t.menu_text } else { t.menu_dim };
|
||||
if checked {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, col);
|
||||
} else if locked {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Lock, col);
|
||||
}
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), col);
|
||||
let keys = (!shortcut.is_empty())
|
||||
|
||||
+2
-2
@@ -2084,8 +2084,8 @@ turned off 0°.
|
||||
`node.set {pointCount}` keeps stars to 3–60 points; `miterAngle` (degrees, the Design panel's
|
||||
Miter angle) sets the miter limit (`limit = 1 / sin(angle / 2)`); `get_node` reports a join
|
||||
other than miter (`strokeJoin`) or a miter angle other than the default ~28.96° (`miterAngle`).
|
||||
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its
|
||||
depth (the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
|
||||
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, locked ones too, front-most first with its
|
||||
depth and a `locked` flag (true when the layer or an ancestor is locked; the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
|
||||
/ Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true`
|
||||
clicks select the nested layer, `cmd` drags from empty canvas select the nested layers they touch,
|
||||
and `shift` drags toggle what they touch. A press on the background of a top-level frame with
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); M8: Scale tool handle drags pivot on the opposite handle whatever the origin, and the origin starts at the centre (measured in Figma's web app, 2026-11-09); M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); Select layer lists locked layers with a padlock and selects them, and shows only with more than one layer under the pointer (#1560); M8: Scale tool handle drags pivot on the opposite handle whatever the origin, and the origin starts at the centre (measured in Figma's web app, 2026-11-09); M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with rLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -61,7 +61,7 @@ interaction feel only partly compared: the interactions measured so far, case by
|
||||
|---|---|---|
|
||||
| Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to the dominant axis with no 45° snap (#498; `shift_drag_moves_the_selection`; measured in Figma's web app, 2026-11-08); a marquee selects the top-level layers it touches, by the box and a rotated shape by its outline (rectangle and ellipse measured, the other shapes by the same code); a partly covered top-level frame or section gives the layers in it the marquee touches, an enclosed one is selected itself, an empty frame on touch; a drag from a frame's background selects that frame only when the marquee also takes another top-level frame (then whole), and also takes what it touches outside it; selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole too); locked and hidden layers are skipped (`marquee_behaviour_tests`, `selection_behaviour_tests`; measured in Figma's web app, 2026-11-11; Shift and Cmd/Ctrl marquees not measured) (a locked layer selected from the panel ignores the arrow keys and the nudge commands; its properties still edit; measured in Figma's web app, 2026-11-10) |
|
||||
| Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | |
|
||||
| Select layer menu (every layer under the click) | done | |
|
||||
| Select layer menu (every layer under the click) | done | shown only with more than one layer under the pointer; locked layers are listed with a padlock in the leading column and are selectable there, as Lock and unlock layers' note says (measured in Figma's web app, 2026-11-10; the "more than one layer" gate was measured on a single locked layer only) (`select_layer_marks_locked_layers_with_a_padlock_and_picks_them`, `select_layer_needs_more_than_one_layer_under_the_pointer`) |
|
||||
| Escape with layers selected | done | clears the selection at once, whatever the nesting; it never selects the parent. Measured in the Figma web app (2026-11-08) with a rectangle in a group in a frame, selected from the canvas (a double-click into the group), from the Layers panel, and after Tab moved the selection to its sibling: one Escape selects nothing, a second leaves nothing selected. Escape still leaves text and vector editing, ends gestures and the pen first, and ends the focus view once nothing or the focused frame is selected (`selection_behaviour_tests`, `devfocus_tests`) |
|
||||
| Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming |
|
||||
| Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`); Shift keeps the ratio (a corner by the axis dragged most, an edge scaling the other axis about the opposite edge's middle), Alt keeps the centre, Shift+Alt does both, each read afresh on every move (`handle_drags_resize_by_the_documented_rules`; measured in Figma's web app, 2026-11-08; Ctrl/Cmd while dragging not measurable) |
|
||||
@@ -216,6 +216,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, the smaller of a tie, else the mean rounded half up) and keeps the other axis, layers of a row may overlap along X (also in a chain, and a negative gap is raised so no layer reaches the one two before it, measured in Figma's web app on 2026-11-13), and a grid is packed along the axis with more groups; fewer than three layers, layers that all overlap, a diagonal and an irregular layout grey the row out and move nothing. Measured: those rules and the unavailable cases; FigCraft's choices: a 2 x 2 grid as columns, auto layout flow children, the 2,000 layer cap, rotated layers by their box, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_mean_is_rounded_half_up, two_equally_common_gaps_give_the_smaller, an_overlapping_pair_in_a_row_is_tidied_and_its_negative_gap_counts, a_grid_is_tidied_by_columns, a_transposed_grid_is_tidied_by_rows, layers_on_a_diagonal_are_unavailable, an_irregular_selection_is_unavailable and the rest). |
|
||||
| 2026-11-12 | patch | M5, #1560: the context menu's Select layer lists locked layers with a padlock in the leading column (the Layers panel's padlock icon; a row both selected and locked shows the check) and picking one selects it, as Lock and unlock layers' note says and as measured in Figma's web app, 2026-11-10; the submenu shows only with more than one layer under the pointer (measured on a single locked layer only: the least certain rule). Left clicks, hover and marquee still never reach a locked layer. `select.layersAt` lists locked layers too and its rows gain `locked`. Tests: `layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them`, `select_layer_marks_locked_layers_with_a_padlock_and_picks_them`, `select_layer_needs_more_than_one_layer_under_the_pointer`. Counts unchanged |
|
||||
| 2026-11-09 | patch | Zoom to selection: the selection is fitted with a 40 pt margin into the canvas above the floating bottom toolbar (`ViewState::bottom_inset`, set by the UI each frame, never saved; `view.set` takes `bottomInset`) and centred in that area, as measured in Figma's web app on 2026-11-09. Zoom to fit is unchanged (margin not measured yet). Numbers unchanged. |
|
||||
| 2026-11-13 | patch | M1: Shift+Cmd/Ctrl+V (Paste over selection) puts the pasted layers just above the selected layer, in its parent and in the copied order, at its position; they went to the top of the parent's layers, so in an auto layout frame they ended the flow instead of following the selected layer. |
|
||||
| 2026-11-12 | patch | Drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis with no 45° snap, Alt copies, Shift keeps a resize's ratio (an edge scales about the opposite edge's middle), Alt keeps the centre, Shift+Alt does both, and a modifier changed mid-drag is read fresh on each move; FigCraft matches in all six, Ctrl/Cmd while dragging could not be measured; no code change | this page |
|
||||
|
||||
Reference in new issue
Block a user