From 40534ddf14f204743e7e6558bb0a9f8a8d71d144 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 31 Oct 2026 11:20:21 -0400 Subject: [PATCH 01/17] M1: the page background opacity can be edited in the Page section (#1748) The Design panel drew the page opacity as a "100 %" label, so it could not be changed from the panel although the model, page.setBackground and the colour picker already handled a translucent page. The label is now a percent number field like the other opacity fields (named Opacity for screen readers), committing page.setBackground; a scrub stays one undo step. The hex field keeps the current opacity unless the typed colour carries one, as the colour picker does. Regression test in field_tests. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/field_tests.rs | 41 ++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 39 +++++++++++++++++++++------- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 566f0828b..63995fc80 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -1194,3 +1194,44 @@ fn sidebar_field_text_stays_in_place_on_focus() { assert!((before.center().y - after.center().y).abs() <= 0.5, "numeric={numeric}: before={before:?}, after={after:?}"); } } + +/// The Page section's opacity field edits the page background's alpha (#1748; it was a label), +/// one undo step per commit, and the hex field keeps that opacity unless the typed colour +/// carries its own. +#[test] +fn the_page_background_opacity_field_sets_the_page_colour_alpha() { + let mut u = Ui::new(Session::new()); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let page_bg = |u: &Ui| { + let page = u.app.session.page().unwrap(); + match &u.app.session.doc().unwrap().node(page).unwrap().kind { + figcraft_doc::NodeKind::Page { background, .. } => *background, + _ => panic!("the page"), + } + }; + // Within an 8-bit step: the colour travels as RRGGBBAA. + let near = |a: f32, b: f32| (a - b).abs() < 1.0 / 255.0; + assert!(near(page_bg(&u).a, 1.0)); + let steps = undo_steps(&u); + type_into(&mut u, "Opacity", "50", Key::Enter); + assert!(near(page_bg(&u).a, 0.5), "{:?}", page_bg(&u)); + assert_eq!(undo_steps(&u), steps + 1, "one undo step for the commit"); + // The hex field keeps the opacity when the typed colour has none, and takes one it carries. + let hex = |u: &mut Ui| u.ctx.read_response(Id::unique("pagebg").with("click")).expect("the hex field").rect.center(); + let at = hex(&mut u); + click(&mut u, at); + u.frame(vec![Event::Text("FF0000".into())]); + key(&mut u, Key::Enter, Modifiers::NONE); + let c = page_bg(&u); + assert!(near(c.r, 1.0) && near(c.g, 0.0) && near(c.a, 0.5), "{c:?}"); + let at = hex(&mut u); + click(&mut u, at); + u.frame(vec![Event::Text("00FF0080".into())]); + key(&mut u, Key::Enter, Modifiers::NONE); + let c = page_bg(&u); + assert!(near(c.g, 1.0) && near(c.a, 128.0 / 255.0), "{c:?}"); + assert_eq!(undo_steps(&u), steps + 3); + u.app.run("edit.undo", json!({})); + assert!(near(page_bg(&u).r, 1.0) && near(page_bg(&u).a, 0.5), "undo takes back the last colour alone"); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6dde13136..4f31f4089 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -311,27 +311,48 @@ fn design(app: &mut FigApp, ui: &mut Ui) { /// Draw the page section: background and colour profile, local variables/styles, and the /// page export section. fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { - let t = Tokens::get(ui.ctx()); let Ok(page) = app.session.page() else { return }; let bg = match doc.node(page).map(|n| &n.kind) { Some(NodeKind::Page { background, .. }) => *background, _ => figcraft_color::Color::DEFAULT_PAGE, }; widgets::section_header(ui, tr("Page"), &[], false); - let mut new_hex = None; + // The new colour and, from a scrub, whether the scrub is over (live edits stay one undo step). + let mut new_color: Option<(figcraft_color::Color, Option)> = None; row(ui, 32.0, |ui| { let p = Paint::solid(bg); let sw = widgets::swatch(ui, &p, 16.0); app.picker_control(crate::colorpicker::PickerTarget::PageBackground, &sw); - if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { - new_hex = Some(h); + // A typed colour keeps the page's opacity unless it carries one (`FF000080`), as the + // colour picker's hex field does. + if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") + && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) + { + new_color = Some((c.with_alpha(if c.a < 1.0 { c.a } else { bg.a }), None)); + } + // The opacity: a field like every other opacity field, not a label (#1748). + let alpha = |v: f64| bg.with_alpha((v / 100.0).clamp(0.0, 1.0) as f32); + let shown = Some(f64::from((bg.a * 100.0).round())); + match widgets::number_field(ui, Id::unique("pagebg_a"), Lead::Named(tr("Opacity")), shown, "%", 56.0, 1.0) { + Some(FieldEdit::Set(v)) => new_color = Some((alpha(v), None)), + Some(FieldEdit::Scrub(v, done)) => new_color = Some((alpha(v), Some(done))), + None => {} } - widgets::label(ui, "100 %", t.text); }); - if let Some(h) = new_hex - && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) - { - app.run("page.setBackground", json!({"color": c.to_css_hex()})); + if let Some((c, scrub)) = new_color { + let p = json!({"color": c.to_css_hex()}); + match scrub { + None => { + app.run("page.setBackground", p); + } + Some(done) => { + let _ = app.session.begin_interaction("Page background"); + let _ = app.session.execute("page.setBackground", &p); + if done { + let _ = app.session.commit_interaction(); + } + } + } } widgets::separator(ui); widgets::section_header(ui, tr("Color profile"), &[], false); From fcab85ddd017e929fe5a38d8c67d3c8e36f31760 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 31 Oct 2026 11:34:55 -0400 Subject: [PATCH 02/17] M4: the Pencil draws white on a dark page or frame, and previews in that colour (#1741) Figma: "a round 3px stroke weight in black, unless you are sketching on a dark canvas or frame". The Pencil always drew black. It now takes the colour from the same dark-background rule new text uses (the nearest ancestor with a mostly opaque solid fill, else the page), and the overlay carries that colour for the stroke in progress so the preview matches. Engine and UI regression tests. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/brush_tests.rs | 32 +++++++++++++++++++++++ crates/engine/src/canvas/tools.rs | 3 +++ crates/engine/src/canvas/tools/draw.rs | 1 + crates/engine/src/canvas/tools/handles.rs | 31 ++++++++++++++++------ crates/engine/src/canvas/tools/pen.rs | 13 ++++++++- crates/engine/src/canvas/tools/pointer.rs | 2 ++ crates/ui-egui/src/canvas/overlays.rs | 6 +++-- crates/ui-egui/src/canvas_fixes_tests.rs | 17 ++++++++++++ 8 files changed, 94 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/brush_tests.rs b/crates/engine/src/brush_tests.rs index e4e8b428b..984eaaad5 100644 --- a/crates/engine/src/brush_tests.rs +++ b/crates/engine/src/brush_tests.rs @@ -107,3 +107,35 @@ fn the_pencil_records_pressure_and_its_brush() { let last = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap(); assert!(last.stroke.profile().is_none() && last.stroke.brush.is_none()); } + +/// The Pencil draws black on a light page and white on a dark page or inside a dark frame, the +/// rule new text follows (#1741; Figma: "in black, unless you're sketching on a dark canvas or +/// frame"), and the stroke in progress previews in that colour. +#[test] +fn the_pencil_draws_white_on_a_dark_background() { + use figcraft_color::Color; + let mut s = Session::new(); + s.execute("tool.set", &json!({"tool": "pencil"})).unwrap(); + // A stroke from (x, y); its colour, and the preview colour the overlay carried while drawing. + let stroke = |s: &mut Session, x: f64, y: f64| { + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, x + 40.0, y + 10.0, 1.0)).unwrap(); + let preview = s.tool.overlay.pen_color; + s.pointer(&PointerEvent::at(PointerKind::Move, x + 80.0, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x + 80.0, y, 1.0)).unwrap(); + assert!(s.tool.overlay.pen_color.is_none(), "no preview colour after the stroke"); + let doc = s.doc().unwrap(); + let last = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap(); + (last.strokes.first().and_then(|p| p.solid_color()).unwrap(), preview) + }; + assert_eq!(stroke(&mut s, 0.0, 0.0), (Color::BLACK, Some(Color::BLACK)), "a light page"); + s.execute("page.setBackground", &json!({"color": "#1E1E1E"})).unwrap(); + assert_eq!(stroke(&mut s, 0.0, 100.0), (Color::WHITE, Some(Color::WHITE)), "a dark page"); + // A dark frame on a light page, and a light frame on a dark page. + s.execute("page.setBackground", &json!({"color": "#F5F5F5"})).unwrap(); + s.execute("node.create", &json!({"type": "frame", "x": 300, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#202020"]}})).unwrap(); + assert_eq!(stroke(&mut s, 320.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "inside a dark frame"); + s.execute("page.setBackground", &json!({"color": "#1E1E1E"})).unwrap(); + s.execute("node.create", &json!({"type": "frame", "x": 600, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#FFFFFF"]}})).unwrap(); + assert_eq!(stroke(&mut s, 620.0, 320.0), (Color::BLACK, Some(Color::BLACK)), "inside a light frame on a dark page"); +} diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index c68276fd3..9b8271695 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -327,6 +327,9 @@ pub struct Overlay { pub cursor: Cursor, /// Pen tool: the in-progress path's anchor points and the rubber band to the pointer. pub pen: Vec<[f64; 2]>, + /// Pencil: the colour of the stroke in progress, for its preview (white over a dark page or + /// frame); None outside a Pencil stroke. + pub pen_color: Option, pub rubber: Option<[f64; 4]>, /// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last /// anchor's in and out handles and the one before's out handle. diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index d59a2b904..7df150e43 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -221,6 +221,7 @@ impl Session { } Gesture::Pencil { mut points, mut pressure } => { self.tool.overlay.pen.clear(); + self.tool.overlay.pen_color = None; // The stroke ends where the button came up, even without a move to get there. if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { points.push(ev.p()); diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index 7ccaca403..9bd1c5973 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -36,16 +36,31 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str { ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") } +/// Whether what is drawn in `parent` sits on a dark background: the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). +pub(crate) fn on_dark_background(doc: &Document, parent: NodeId) -> bool { + std::iter::once(parent) + .chain(doc.ancestors(parent)) + .filter_map(|id| doc.node(id)) + .find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }) + .unwrap_or(false) +} + /// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark -/// background (None: the model's default, black). The background is the nearest layer from `parent` -/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made -/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +/// background (None: the model's default, black). For text made in the app: by the Text tool, or +/// by a command that names no fill while the interface is up. pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { - let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { - NodeKind::Page { background, .. } => Some(background.is_dark()), - _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), - }); - dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) + on_dark_background(doc, parent).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +} + +/// The colour the Pencil draws with in `parent`: [`PENCIL_STROKE`]'s black, or white on a dark +/// background by the rule text follows (#1741; "in black, unless you're sketching on a dark canvas +/// or frame"). +pub(crate) fn pencil_color(doc: &Document, parent: NodeId) -> figcraft_color::Color { + if on_dark_background(doc, parent) { figcraft_color::Color::WHITE } else { PENCIL_STROKE.0 } } const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs index d519bc580..8de56bec7 100644 --- a/crates/engine/src/canvas/tools/pen.rs +++ b/crates/engine/src/canvas/tools/pen.rs @@ -296,7 +296,9 @@ impl Session { let mut local = fitted.clone(); local.apply_affine(pw.inverse()); let _ = d; - let (color, weight) = PENCIL_STROKE; + // Black, or white on a dark page or frame (#1741). + let (_, weight) = PENCIL_STROKE; + let color = super::handles::pencil_color(self.doc()?, parent); let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); // Pen pressure becomes the stroke's width profile (brushes follow it too). if let Some(points) = pressure_profile(points, pressure) { @@ -311,4 +313,13 @@ impl Session { )?; Ok(()) } + + /// The colour a Pencil stroke starting at `p` will have: it goes into the frame under `p` + /// (else the page), and is white when that is dark (`pencil_color`). + pub(super) fn pencil_color_at(&self, p: figcraft_geom::Point) -> Result { + let page = self.page()?; + let doc = self.doc()?; + let parent = frame_at(doc, page, p, &[]).unwrap_or(page); + Ok(super::handles::pencil_color(doc, parent)) + } } diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs index 1493604dc..bd4fa94e7 100644 --- a/crates/engine/src/canvas/tools/pointer.rs +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -156,6 +156,8 @@ impl Session { ToolId::TextOnPath => self.text_on_path_down(ev), ToolId::Pen => self.pen_down(ev), ToolId::Pencil => { + // The preview draws in the colour the stroke will have (#1741). + self.tool.overlay.pen_color = self.pencil_color_at(ev.p()).ok(); self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; Ok(()) } diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 483a180b9..2434bbf7f 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -421,8 +421,10 @@ fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) { if !ov.pen.is_empty() { let pts: Vec = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect(); if app.session.tool.tool == ToolId::Pencil { - // The stroke it will make, in its colour and weight (at least a point when zoomed out). - let (color, weight) = figcraft_engine::tools::PENCIL_STROKE; + // The stroke it will make, in its colour and weight (at least a point when zoomed out): + // white over a dark page or frame, as the stroke will be (#1741). + let (default, weight) = figcraft_engine::tools::PENCIL_STROKE; + let color = ov.pen_color.unwrap_or(default); painter.add(Shape::line(pts, stroke(doc_c32(painter.ctx(), color), ((weight * zoom) as f32).max(1.0)))); } else { for [x0, y0, x1, y1] in &ov.pen_handles { diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index d8112a3aa..cdb19492a 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -186,6 +186,23 @@ fn the_pencil_previews_its_stroke_in_its_colour_and_weight() { assert!((w - 1.0).abs() < 0.01, "at least a point: {w}"); } +/// Over a dark page the Pencil's preview is white, as the stroke it makes will be (#1741). +#[test] +fn the_pencil_previews_white_over_a_dark_page() { + let mut u = Ui::new(Session::new()); + u.app.run("page.setBackground", json!({"color": "#1E1E1E"})); + u.app.run("tool.set", json!({"tool": "pencil"})); + let white = egui::epaint::ColorMode::Solid(crate::theme::c32(figcraft_color::Color::WHITE)); + let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y); + u.button(at(&u, 10.0, 10.0), true); + for i in 1..=6 { + let p = at(&u, 10.0 + 12.0 * f64::from(i), 10.0 + 6.0 * f64::from(i % 2)); + u.frame(vec![egui::Event::PointerMoved(p)]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(shapes.iter().any(|s| matches!(s, Shape::Path(p) if !p.closed && p.points.len() >= 3 && p.stroke.color == white)), "a white preview"); +} + #[test] fn a_selected_layer_shows_its_constraint_guides_in_its_frame() { // A child pinned left (the default) in a frame without auto layout: a dashed guide runs from its From f137e9df9a8bf9ced198f8faa7177bd1ca242798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Legrand?= Date: Sat, 31 Oct 2026 20:29:10 +0100 Subject: [PATCH 03/17] fix(pathops): M2.1: keep Fenwick indices Rust 1.95 compatible --- crates/pathops/src/budget.rs | 4 ++-- crates/pathops/src/budget_tests.rs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/pathops/src/budget.rs b/crates/pathops/src/budget.rs index ff6f9cab4..714611ceb 100644 --- a/crates/pathops/src/budget.rs +++ b/crates/pathops/src/budget.rs @@ -112,7 +112,7 @@ impl Fenwick { let mut i = rank + 1; while let Some(c) = self.0.get_mut(i) { *c += by; - i += i.isolate_lowest_one(); + i += i & i.wrapping_neg(); } } @@ -123,7 +123,7 @@ impl Fenwick { && let Some(c) = self.0.get(i) { n += c; - i -= i.isolate_lowest_one(); + i -= i & i.wrapping_neg(); } u64::try_from(n).unwrap_or(0) } diff --git a/crates/pathops/src/budget_tests.rs b/crates/pathops/src/budget_tests.rs index b53b5545d..a55e58e85 100644 --- a/crates/pathops/src/budget_tests.rs +++ b/crates/pathops/src/budget_tests.rs @@ -93,3 +93,9 @@ fn a_fold_stops_once_it_has_swept_its_budget() { assert_eq!(steps, MAX_FOLD_SEGMENTS / 100_004); assert_eq!(budget.spend(&BezPath::new(), &BezPath::new()), Err(PathOpsError::TooComplex), "spent stays spent"); } + +#[test] +fn overlapping_rectangle_bounds_count_self_and_cross_pairs() { + let shapes = rects([Rect::new(0.0, 0.0, 2.0, 2.0), Rect::new(1.0, 1.0, 3.0, 3.0), Rect::new(4.0, 4.0, 5.0, 5.0)]); + assert_eq!(complexity([&shapes]), Some(Complexity { segments: 12, crossings: 14 })); +} From e3df030be97c427b070cfee648cc2daf07ae35f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Legrand?= Date: Sat, 31 Oct 2026 20:45:51 +0100 Subject: [PATCH 04/17] fix(text): M2.1: simplify font file filter --- crates/text/src/fontdb.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c6aac948..6ebd7ec45 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -1304,7 +1304,7 @@ fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut B return; } let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); - if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(path)) { + if !(matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") || path.extension().is_none() && starts_like_a_font(path)) { return; } // Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the From 25b128e5385af20f1b7f34ab6f68cf4eebf40d54 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 00:14:22 -0400 Subject: [PATCH 05/17] Pencil colour: document the dark threshold; tests for a frame without a fill, a see-through fill and the threshold Review follow-up on #1775: the dark rule is the one text uses (on_dark_background); a frame with no fill, or a fill under 50% alpha, is looked through to what is behind it, and the threshold is Color::is_dark (#757575 is dark, #767676 is light). Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/brush_tests.rs | 10 ++++++++++ crates/engine/src/canvas/tools/handles.rs | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/brush_tests.rs b/crates/engine/src/brush_tests.rs index 984eaaad5..35dba6a7f 100644 --- a/crates/engine/src/brush_tests.rs +++ b/crates/engine/src/brush_tests.rs @@ -138,4 +138,14 @@ fn the_pencil_draws_white_on_a_dark_background() { s.execute("page.setBackground", &json!({"color": "#1E1E1E"})).unwrap(); s.execute("node.create", &json!({"type": "frame", "x": 600, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#FFFFFF"]}})).unwrap(); assert_eq!(stroke(&mut s, 620.0, 320.0), (Color::BLACK, Some(Color::BLACK)), "inside a light frame on a dark page"); + // A frame without a fill, or with a see-through one, is looked through to what is behind it. + s.execute("node.create", &json!({"type": "frame", "x": 900, "y": 300, "width": 200, "height": 200, "props": {"fills": []}})).unwrap(); + assert_eq!(stroke(&mut s, 920.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "a frame without a fill on a dark page"); + s.execute("node.create", &json!({"type": "frame", "x": 1200, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#FFFFFF40"]}})).unwrap(); + assert_eq!(stroke(&mut s, 1220.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "a mostly transparent light frame on a dark page"); + // The threshold is Color::is_dark: white reads better than black from #757575 down. + for (bg, want) in [("#767676", Color::BLACK), ("#757575", Color::WHITE)] { + s.execute("page.setBackground", &json!({"color": bg})).unwrap(); + assert_eq!(stroke(&mut s, 0.0, 600.0).0, want, "on {bg}"); + } } diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index 9bd1c5973..9fa04d9bb 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -37,7 +37,9 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str { } /// Whether what is drawn in `parent` sits on a dark background: the nearest layer from `parent` -/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). +/// out whose top visible fill is a mostly opaque (alpha 0.5 or more) solid colour, else the page +/// (#362); a frame without such a fill is looked through to what is behind it. Dark means white +/// reads better on it than black (`Color::is_dark`: `#757575` is dark, `#767676` is light). pub(crate) fn on_dark_background(doc: &Document, parent: NodeId) -> bool { std::iter::once(parent) .chain(doc.ancestors(parent)) From 16b13919aa72c3eff4c8f3a52140bf8771492ce9 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 00:20:29 -0400 Subject: [PATCH 06/17] Page hex field: an 8-digit hex asking for opaque sets 100% Review follow-up on #1770: the typed colour keeps the page opacity only when the text carries no alpha (not 4 or 8 hex digits); FF0000FF on a translucent page now sets 100%. Test extended. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/field_tests.rs | 11 +++++++++-- crates/ui-egui/src/panels/right.rs | 8 +++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 63995fc80..8802bb7e9 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -1231,7 +1231,14 @@ fn the_page_background_opacity_field_sets_the_page_colour_alpha() { key(&mut u, Key::Enter, Modifiers::NONE); let c = page_bg(&u); assert!(near(c.g, 1.0) && near(c.a, 128.0 / 255.0), "{c:?}"); - assert_eq!(undo_steps(&u), steps + 3); + // Eight digits asking for opaque set 100 %: an alpha of 1 in the text is not "no alpha". + let at = hex(&mut u); + click(&mut u, at); + u.frame(vec![Event::Text("0000FFFF".into())]); + key(&mut u, Key::Enter, Modifiers::NONE); + let c = page_bg(&u); + assert!(near(c.b, 1.0) && near(c.a, 1.0), "{c:?}"); + assert_eq!(undo_steps(&u), steps + 4); u.app.run("edit.undo", json!({})); - assert!(near(page_bg(&u).r, 1.0) && near(page_bg(&u).a, 0.5), "undo takes back the last colour alone"); + assert!(near(page_bg(&u).g, 1.0) && near(page_bg(&u).a, 128.0 / 255.0), "undo takes back the last colour alone"); } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 4f31f4089..e9987ec49 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -323,12 +323,14 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let p = Paint::solid(bg); let sw = widgets::swatch(ui, &p, 16.0); app.picker_control(crate::colorpicker::PickerTarget::PageBackground, &sw); - // A typed colour keeps the page's opacity unless it carries one (`FF000080`), as the - // colour picker's hex field does. + // A typed colour keeps the page's opacity unless it carries one: 4 or 8 hex digits + // (`FF000080`; `FF0000FF` asks for opaque). if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { - new_color = Some((c.with_alpha(if c.a < 1.0 { c.a } else { bg.a }), None)); + let digits = h.trim().trim_start_matches('#'); + let carries_alpha = matches!(digits.len(), 4 | 8) && digits.chars().all(|ch| ch.is_ascii_hexdigit()); + new_color = Some((if carries_alpha { c } else { c.with_alpha(bg.a) }, None)); } // The opacity: a field like every other opacity field, not a label (#1748). let alpha = |v: f64| bg.with_alpha((v / 100.0).clamp(0.0, 1.0) as f32); From 4e22873b44dfcf5ec6d5a8e7fd8b077501b07662 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 12:01:33 -0500 Subject: [PATCH 07/17] Page opacity test: a scrub on the field is live and one undo step Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/field_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 8802bb7e9..a6fd9830f 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -1217,6 +1217,14 @@ fn the_page_background_opacity_field_sets_the_page_colour_alpha() { type_into(&mut u, "Opacity", "50", Key::Enter); assert!(near(page_bg(&u).a, 0.5), "{:?}", page_bg(&u)); assert_eq!(undo_steps(&u), steps + 1, "one undo step for the commit"); + // No label is drawn, so the field's leading strip scrubs it: a percent a point, live, and one + // undo step for the whole drag. + let edge = lead(&u, Id::unique("pagebg_a")); + drag(&mut u, edge, -25.0, Modifiers::NONE); + assert!(near(page_bg(&u).a, 0.25), "{:?}", page_bg(&u)); + assert_eq!(undo_steps(&u), steps + 2, "one undo step for the whole scrub"); + u.app.run("edit.undo", json!({})); + assert!(near(page_bg(&u).a, 0.5), "undo takes back the scrub alone"); // The hex field keeps the opacity when the typed colour has none, and takes one it carries. let hex = |u: &mut Ui| u.ctx.read_response(Id::unique("pagebg").with("click")).expect("the hex field").rect.center(); let at = hex(&mut u); From e7a599baea9f3accbb9dd1ef561fc03566d1875a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Legrand?= Date: Sun, 1 Nov 2026 19:42:52 +0100 Subject: [PATCH 08/17] style(text): M2.1: clarify font filter precedence --- crates/text/src/fontdb.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 6ebd7ec45..ac851647b 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -1304,7 +1304,7 @@ fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut B return; } let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); - if !(matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") || path.extension().is_none() && starts_like_a_font(path)) { + if !(matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") || (path.extension().is_none() && starts_like_a_font(path))) { return; } // Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the From c41471d20bdde3e7308afce5fe92a884f1873a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Legrand?= Date: Sun, 1 Nov 2026 19:54:54 +0100 Subject: [PATCH 09/17] build(pathops): M2.1: inherit workspace Rust version --- crates/pathops/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/pathops/Cargo.toml b/crates/pathops/Cargo.toml index 27e2b7200..207a79cdf 100644 --- a/crates/pathops/Cargo.toml +++ b/crates/pathops/Cargo.toml @@ -3,6 +3,7 @@ name = "figcraft-pathops" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true description = "FigCraft path operations: booleans (union, subtract, intersect, exclude), outline stroke with inside/center/outside alignment, arrowheads" [lints] From ed2747e9a4a4487c2e0202386b0e3c51c030ea31 Mon Sep 17 00:00:00 2001 From: samhyun Date: Mon, 2 Nov 2026 10:36:05 +0900 Subject: [PATCH 10/17] M8: a stroke's square dash ends and brush shapes paint within its bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways a stroke painted past the bounds an export and a redraw go by, with no effects at all: - A dash's square end stands out along the path as far as the band does across it, so its corners reach √2 times as far from the path. Round a curve that is past the box grown by the stroke. - The ink brush's ragged edges reach 0.54 of the weight from the path, not 0.5. - A scatter brush's copies stray by the weight whatever a width profile does, but the profile's scale was applied to the stray as well, so a thinning profile left them out. `outline_reach` now counts square dash ends as √2 at least, and `Brush::reach_factor` takes the profile's scale for the shapes only and counts the shapes' reach (`Brush::SHAPE_REACH`), which a test checks every preset against. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/node.rs | 6 ++- crates/doc/src/props.rs | 15 +++++-- crates/pathops/src/brush_tests.rs | 21 +++++++--- crates/render/src/lib.rs | 2 + crates/render/src/stroke_reach_tests.rs | 54 +++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 crates/render/src/stroke_reach_tests.rs diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index e24057f7b..ce7d75ec3 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -945,7 +945,11 @@ impl Node { // Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown // box; an arc's corners don't. let miter = if self.outline_mitres_end_on_its_box() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) }; - base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter + // A dash's square end stands out along the path as far as the band does across it: + // its corners reach √2 times as far from the path, past the box round a curve. + let square_dashes = !self.stroke.dashes.is_empty() && self.stroke.band_caps().contains(&StrokeCap::Square); + let corners = if square_dashes { miter.max(std::f64::consts::SQRT_2) } else { miter }; + base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * corners } else { 0.0 }; diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 0c78681f2..1c6cd6e3e 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -146,10 +146,16 @@ impl Brush { } } - /// How much wider than the stroke weight the brush can paint (larger copies, strays). - pub fn reach_factor(&self) -> f64 { + /// The farthest a built-in brush shape reaches from the path, in stroke weights: the ink + /// brush's ragged edges stand a little past half the weight. + pub const SHAPE_REACH: f64 = 0.54; + + /// How much wider than the stroke weight the brush can paint where a width profile scales it + /// at most `scale`: its shapes' edges and larger copies, which the profile scales, and strays, + /// which it doesn't. + pub fn reach_factor(&self, scale: f64) -> f64 { let b = self.sanitized(); - 1.0 + b.size_jitter + 2.0 * b.scatter + 2.0 * Self::SHAPE_REACH * (1.0 + b.size_jitter) * scale + 2.0 * b.scatter } } @@ -188,7 +194,8 @@ impl StrokeProps { /// The widest the stroke gets anywhere along the path (the weight, scaled by a width profile /// and widened by a brush's reach). pub fn max_weight(&self) -> f64 { - self.profile().map_or(self.weight, |p| self.weight * p.max_scale()) * self.brush.as_ref().map_or(1.0, Brush::reach_factor) + let scale = self.profile().map_or(1.0, WidthProfile::max_scale); + self.weight * self.brush.as_ref().map_or(scale, |b| b.reach_factor(scale)) } /// The miter limit as the angle (degrees) below which joins bevel: limit = 1 / sin(angle / 2), diff --git a/crates/pathops/src/brush_tests.rs b/crates/pathops/src/brush_tests.rs index 403935ddd..b97356362 100644 --- a/crates/pathops/src/brush_tests.rs +++ b/crates/pathops/src/brush_tests.rs @@ -21,14 +21,25 @@ fn props(weight: f64, b: &Brush) -> StrokeProps { StrokeProps { weight, align: StrokeAlign::Center, brush: Some(b.clone()), ..Default::default() } } +/// What a brush paints stays within half [`StrokeProps::max_weight`] of the path, which the +/// stroke's bounds go by: plain, with copies grown, turned and strayed all they can, and with a +/// width profile thinning or widening it (strays keep their distance, whatever the profile). #[test] fn every_preset_draws_inside_its_reach() { for p in PRESETS { - let b = brush(p.id); - let out = outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap(); - let bb = out.bounding_box(); - assert!(!out.elements().is_empty(), "{}", p.id); - assert!(bb.y0 >= -5.5 && bb.y1 <= 5.5 && bb.x0 >= -5.5 && bb.x1 <= 205.5, "{}: {bb:?}", p.id); + let wild = Brush { size_jitter: 1.0, rotation_jitter: 1.0, scatter: 4.0, ..brush(p.id) }; + for (b, profile) in [(brush(p.id), None), (wild.clone(), None), (wild.clone(), Some(0.2)), (wild, Some(2.0))] { + let k = StrokeProps { width_profile: profile.map(|s| WidthProfile::new([(0.0, s), (1.0, s)])), ..props(10.0, &b) }; + let out = outline(&line(200.0), 10.0, &k, &b).unwrap(); + let bb = out.bounding_box(); + let reach = k.max_weight() / 2.0; + assert!(!out.elements().is_empty(), "{}", p.id); + assert!( + bb.y0 >= -reach && bb.y1 <= reach && bb.x0 >= -reach && bb.x1 <= 200.0 + reach, + "{} {b:?} {profile:?}: {bb:?}, reach {reach}", + p.id + ); + } } assert!(outline(&line(10.0), 1.0, &props(1.0, &brush("nope")), &brush("nope")).is_none()); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f1ab0ea5f..5276d2e64 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -564,6 +564,8 @@ mod solo_tests; #[cfg(test)] mod spread_tests; #[cfg(test)] +mod stroke_reach_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod width_tests; diff --git a/crates/render/src/stroke_reach_tests.rs b/crates/render/src/stroke_reach_tests.rs new file mode 100644 index 000000000..f447e93bd --- /dev/null +++ b/crates/render/src/stroke_reach_tests.rs @@ -0,0 +1,54 @@ +//! What a stroke paints stays within the bounds an export and a redraw go by: square dash ends +//! that stand out round a curve, brush shapes past half the weight and brush copies that stray +//! further than a width profile thins them included. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Brush, Document, Node, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, WidthProfile}; + +use super::*; + +/// How many pixels `kind` (at (200, 200), `size` square) with a 40-unit stroke styled by `style` +/// paints more than a pixel outside its export bounds, on a 600 × 600 view. +fn painted_outside(kind: &NodeKind, size: f64, align: StrokeAlign, miter_limit: f64, style: fn(&mut Node)) -> usize { + let mut d = Document::new("reach"); + let p = d.first_page().unwrap(); + let n = d.create(p, kind.clone(), "S", 200.0, 200.0, size, size).unwrap(); + let node = d.get_mut(n).unwrap(); + node.fills.clear(); + node.corners.radii = [30.0; 4]; + node.strokes = vec![Paint::solid(Color::WHITE)]; + (node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (40.0, align, StrokeJoin::Miter, miter_limit); + style(node); + let b = crate::export_bounds(&d, n).unwrap(); + let mut pix = Pixmap::new(600, 600).unwrap(); + render_nodes_into(&d, &[n], &View { origin: (0.0, 0.0), scale: 1.0, width: 600, height: 600 }, &mut pix); + let (xs, ys) = (b.x0 - 1.0..=b.x1 + 1.0, b.y0 - 1.0..=b.y1 + 1.0); + let inside = |i: usize| xs.contains(&((i % 600) as f64 + 0.5)) && ys.contains(&((i / 600) as f64 + 0.5)); + pix.data().chunks(4).enumerate().filter(|(i, p)| p[3] > 0 && !inside(*i)).count() +} + +#[test] +fn a_stroke_with_corners_paints_within_its_bounds() { + type Style = fn(&mut Node); + let strokes: [(&str, Style); 4] = [ + ("square dash ends", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![30.0, 30.0], Some(StrokeCap::Square))), + ("short square dashes", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![1.0, 7.0], Some(StrokeCap::Square))), + ("the ink brush", |n| n.stroke.brush = Some(Brush::new("ink"))), + ("a thinned scatter brush", |n| { + n.stroke.brush = Some(Brush { scatter: 1.0, ..Brush::new("dots") }); + n.stroke.width_profile = Some(WidthProfile::new([(0.0, 0.5), (1.0, 0.5)])); + }), + ]; + let circle = NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }; + let shapes = [(&circle, 200.0), (&circle, 30.0), (&NodeKind::Rectangle, 200.0), (&NodeKind::Star { count: 5, inner_ratio: 0.4 }, 200.0)]; + for (kind, size) in shapes { + for (name, style) in strokes { + for align in [StrokeAlign::Center, StrokeAlign::Outside] { + for miter_limit in [4.0, 1.0] { + let outside = painted_outside(kind, size, align, miter_limit, style); + assert_eq!(outside, 0, "{kind:?} {size}, {name}, {align:?}, mitre limit {miter_limit}"); + } + } + } + } +} From 2e392e99b1d2310cb9f48a7e5a47fab098e07ffa Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 07:55:27 -0500 Subject: [PATCH 11/17] M8: a fill per region: the Fill section and the fill commands edit the regions selected in vector edit mode (#1875) fill.set/add/remove/move/paste on the edited layer go to the selected regions' own fill stacks (or those named by regions), the layer's fills untouched; the Fill section and the colour picker list them. Test regions_tests::the_fill_commands_edit_the_selected_regions; docs. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/canvas/vectoredit.rs | 15 +++ .../src/canvas/vectoredit/regions_tests.rs | 47 ++++++++ crates/engine/src/cmd/edit.rs | 23 +++- crates/engine/src/cmd/edit/props.rs | 112 ++++++++++++++++-- crates/engine/src/cmd/lists.rs | 17 ++- crates/ui-egui/src/panels/right/paints.rs | 8 +- crates/ui-egui/src/widgets/colorpicker.rs | 7 ++ docs/mcp.md | 5 +- docs/ui-parity.md | 5 +- 9 files changed, 219 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index 078f0804e..5d5408d02 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -1245,6 +1245,21 @@ pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result Ok(filled) } +/// The fills the Fill section shows for layer `id` while regions of it are selected in edit +/// mode: the stack those regions share (their own, else the layer's), `Some(None)` when they +/// differ (mixed), `None` when no region of that layer is selected (the layer's fills show). The +/// fill commands edit the same regions then ("you can adjust the fill for each region +/// individually", Edit vector layers, #1875). +pub fn selected_region_fills(s: &Session, id: NodeId) -> Option>> { + let ve = s.ds().ok()?.vector_edit.as_ref().filter(|ve| ve.id == id && !ve.regions.is_empty())?; + let doc = s.doc().ok()?; + let node = doc.node(id)?; + let net = network(doc, id)?; + let mut stacks = ve.regions.iter().filter_map(|r| net.regions.get(*r)).map(|r| r.fills.clone().unwrap_or_else(|| node.fills.clone())); + let first = stacks.next()?; + Some(stacks.all(|st| st == first).then_some(first)) +} + /// Add points `vs` of edited layer `id` to its selection. pub(crate) fn add_points(ve: &mut VectorEdit, id: NodeId, vs: &[usize]) { let list = if id == ve.id { Some(&mut ve.vertices) } else { ve.others.iter_mut().find(|o| o.id == id).map(|o| &mut o.vertices) }; diff --git a/crates/engine/src/canvas/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs index 6787a59f9..c0b762aff 100644 --- a/crates/engine/src/canvas/vectoredit/regions_tests.rs +++ b/crates/engine/src/canvas/vectoredit/regions_tests.rs @@ -6,6 +6,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2}; use serde_json::json; use super::regions::*; +use super::selected_region_fills; use super::tests::{ex, net, ve}; use crate::Session; use crate::tools::{PointerEvent, PointerKind}; @@ -540,3 +541,49 @@ fn hovered_and_selected_is_hatched_once_and_regions_share_one_pattern() { assert!(!part.is_empty() && part.len() < all.len()); assert!(part.iter().all(|x| all.iter().any(|y| (x - y).abs() < 1e-6))); } + +/// "If there are multiple closed regions within a vector network, you can adjust the fill for each +/// region individually" (Edit vector layers, #1875): with regions selected in edit mode, the fill +/// commands edit those regions' own fills and leave the layer's; `regions` names them outright. +#[test] +fn the_fill_commands_edit_the_selected_regions() { + let mut s = Session::new(); + let id = editing_vector(&mut s, overlapping()); + let fills_of = |s: &Session, r: usize| net(s, id).regions[r].fills.clone(); + let layer = |s: &Session| s.doc().unwrap().node(id).unwrap().fills.clone(); + let colour = |s: &Session, r: usize, i: usize| fills_of(s, r).unwrap()[i].solid_color().unwrap().to_css_hex().to_ascii_uppercase(); + let before = layer(&s); + ex(&mut s, "vector.edit.select", json!({"regions": [1]})); + ex(&mut s, "fill.set", json!({"ids": [id], "color": "#FF0000"})); + assert_eq!(fills_of(&s, 1).map(|f| f.len()), Some(1)); + assert_eq!(colour(&s, 1, 0), "#FF0000"); + assert_eq!(fills_of(&s, 0), None, "the other region still inherits"); + assert_eq!(layer(&s), before, "the layer's fills are untouched"); + // The section's list for the layer is the region's own stack. + assert_eq!(selected_region_fills(&s, id).and_then(|l| l).map(|l| l.len()), Some(1)); + ex(&mut s, "fill.add", json!({"ids": [id], "color": "#00FF00"})); + assert_eq!(fills_of(&s, 1).unwrap().len(), 2); + ex(&mut s, "fill.move", json!({"ids": [id], "from": 0, "to": 1})); + assert_eq!((colour(&s, 1, 0), colour(&s, 1, 1)), ("#00FF00".to_string(), "#FF0000".to_string())); + ex(&mut s, "fill.remove", json!({"ids": [id], "index": 0})); + assert_eq!(fills_of(&s, 1).unwrap().len(), 1); + assert_eq!(colour(&s, 1, 0), "#FF0000"); + assert_eq!(layer(&s), before); + // Both regions selected with different fills: the section shows mixed content. + ex(&mut s, "vector.edit.select", json!({"regions": [0, 1]})); + assert_eq!(selected_region_fills(&s, id), Some(None)); + // Named outright, with no selection; junk is refused. + ex(&mut s, "vector.edit.select", json!({})); + ex(&mut s, "fill.set", json!({"ids": [id], "regions": [0], "color": "#0000FF"})); + assert_eq!(colour(&s, 0, 0), "#0000FF"); + assert!(s.execute("fill.set", &json!({"ids": [id], "regions": [9], "color": "#0000FF"})).is_err()); + assert!(s.execute("fill.set", &json!({"ids": [id], "regions": "all", "color": "#0000FF"})).is_err()); + assert!(s.execute("fill.set", &json!({"ids": [id], "regions": [], "color": "#0000FF"})).is_err()); + // Nothing selected: the layer's fills, as before; the regions keep their own. + ex(&mut s, "fill.set", json!({"ids": [id], "color": "#123456"})); + assert_ne!(layer(&s), before); + assert_eq!(colour(&s, 1, 0), "#FF0000"); + assert_eq!(selected_region_fills(&s, id), None); + s.undo().unwrap(); + assert_eq!(layer(&s), before, "one undo step per command"); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 700fa7414..1bd9bf45e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -15,6 +15,7 @@ use crate::{BatchStep, EngineError, Result, Session, bad, spec}; mod clipboard; mod props; +pub(crate) use props::{edit_region_fills, fill_regions}; mod select; pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into}; @@ -330,14 +331,28 @@ pub fn specs() -> Vec { s.doc_mut()?.reorder(i, idx)?; ok() }), - spec!("fill.add", "Add fill", "{ids?, paint? | color? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)), + spec!( + "fill.add", + "Add fill", + "{ids?, paint? | color? (default #D9D9D9 or white for frames), regions? (a vector's regions by index: their own fills, as the regions selected in edit mode)}", + true, + |s, p| paint_list(s, p, true, true) + ), spec!("stroke.add", "Add stroke", "{ids?, paint? | color? (default black)}", true, |s, p| paint_list(s, p, false, true)), - spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)), + spec!("fill.remove", "Remove fill", "{ids?, index, regions?}", true, |s, p| paint_list(s, p, true, false)), spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)), - spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)), + spec!( + "fill.set", + "Set fill", + "{ids?, index?, paint | color, opacity?, visible?, blend?, regions? (a vector's regions by index: their own fills, as the regions selected in edit mode)}", + true, + |s, p| paint_set(s, p, true) + ), spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)), spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)), - spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)), + spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one), regions?}", true, |s, p| paint_paste( + s, p, true + )), spec!( "stroke.copy", "Copy stroke", diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index da19b1eec..f11c228cb 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -260,10 +260,83 @@ pub(super) fn reparent(s: &mut Session, p: &Value) -> Result { ok() } +/// The regions of a vector whose own fill stacks a fill command edits instead of the layer's: the +/// `regions` named (with one target, a vector; indices into its network's regions), else the +/// regions selected in vector edit mode when the one target is the layer being edited ("you can +/// adjust the fill for each region individually", Edit vector layers, #1875). None: the layer's. +pub(crate) fn fill_regions(s: &Session, cmd: &str, p: &Value, ids: &[NodeId]) -> Result)>> { + let doc = s.doc()?; + let region_count = |id: NodeId| match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Vector { network }) => Some(network.regions.len()), + _ => None, + }; + if let Some(list) = get(p, "regions") { + let [id] = ids else { return Err(bad(cmd, "`regions` goes with one vector layer")) }; + let Some(count) = region_count(*id) else { return Err(bad(cmd, "`regions` goes with a vector layer")) }; + let regions = list + .as_array() + .ok_or_else(|| bad(cmd, "regions: [index, ...]"))? + .iter() + .map(|v| { + v.as_u64() + .and_then(|i| usize::try_from(i).ok()) + .filter(|i| *i < count) + .ok_or_else(|| bad(cmd, format!("regions: indices below {count}"))) + }) + .collect::>>()?; + if regions.is_empty() { + return Err(bad(cmd, "regions: at least one")); + } + return Ok(Some((*id, regions))); + } + let [id] = ids else { return Ok(None) }; + let Some(ve) = s.ds()?.vector_edit.as_ref().filter(|ve| ve.id == *id && !ve.regions.is_empty()) else { return Ok(None) }; + let count = region_count(*id).unwrap_or(0); + let regions: Vec = ve.regions.iter().copied().filter(|r| *r < count).collect(); + Ok((!regions.is_empty()).then_some((*id, regions))) +} + +/// Runs `f` on the own fill stack of each of `regions` of vector `id` (a region without one +/// starts from the layer's fills, which stay as they are). +pub(crate) fn edit_region_fills( + doc: &mut figcraft_doc::Document, + id: NodeId, + regions: &[usize], + mut f: impl FnMut(&mut Vec) -> Result<()>, +) -> Result<()> { + let inherited = doc.get(id)?.fills.clone(); + let NodeKind::Vector { network } = &mut doc.get_mut(id)?.kind else { return Err(bad("fill", "not a vector layer")) }; + for &r in regions { + let Some(region) = network.regions.get_mut(r) else { continue }; + let mut list = region.fills.take().unwrap_or_else(|| inherited.clone()); + f(&mut list)?; + region.fills = Some(list); + } + Ok(()) +} + pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result { let cmd = if fills { "fill" } else { "stroke" }; let ids = req_targets(cmd, s, p)?; let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; + if let Some((id, regions)) = fill_regions(s, cmd, p, &ids)?.filter(|_| fills) { + let paint = match get(p, "paint").or_else(|| get(p, "color")) { + Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), + None => None, + }; + edit_region_fills(s.doc_mut()?, id, ®ions, |list| { + if add { + list.push(paint.clone().unwrap_or_else(|| figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE))); + } else { + let idx = index.unwrap_or(list.len().saturating_sub(1)); + if idx < list.len() { + let _ = list.remove(idx); + } + } + Ok(()) + })?; + return ok(); + } let doc = s.doc_mut()?; for i in ids { let n = doc.get_mut(i)?; @@ -295,16 +368,8 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result None, }; let col = color(p, "color"); - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - let list = if fills { &mut n.fills } else { &mut n.strokes }; - let at = idx.unwrap_or(list.len().saturating_sub(1)); - if list.is_empty() { - list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); - } - let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; - let old = slot.clone(); + // The edit of one paint slot, the same for a layer's list and a region's. + let edit_slot = |slot: &mut figcraft_color::Paint| { if let Some(np) = &new_paint { *slot = np.clone(); } @@ -325,6 +390,30 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result Result { q["index"] = json!(at); diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index e53291b6d..19be64606 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -15,7 +15,7 @@ use crate::{Result, Session, spec}; pub fn specs() -> Vec { vec![ - spec!("fill.move", "Move fill", "{ids?, from, to} (indices in the fill list, bottom first)", true, |s, p| { + spec!("fill.move", "Move fill", "{ids?, from, to, regions?} (indices in the fill list, bottom first)", true, |s, p| { move_item(s, p, "fill.move", BoundList::Fills) }), spec!("stroke.move", "Move stroke", "{ids?, from, to} (indices in the stroke list, bottom first)", true, |s, p| { @@ -249,7 +249,20 @@ fn reorder(v: &mut Vec, from: usize, to: usize) -> bool { fn move_item(s: &mut Session, p: &Value, cmd: &str, list: BoundList) -> Result { let from = req_index(cmd, p, "from")?; let to = req_index(cmd, p, "to")?; - for id in req_targets(cmd, s, p)? { + let ids = req_targets(cmd, s, p)?; + // Fills of the regions selected (or named) in a vector: their own stacks move (#1875). + if matches!(list, BoundList::Fills) + && let Some((id, regions)) = crate::cmd::edit::fill_regions(s, cmd, p, &ids)? + { + crate::cmd::edit::edit_region_fills(s.doc_mut()?, id, ®ions, |fills| { + if from < fills.len() && to < fills.len() { + reorder(fills, from, to); + } + Ok(()) + })?; + return ok(); + } + for id in ids { let len = s.doc()?.node(id).map(|n| list.len(n)).unwrap_or(0); if from >= len || to >= len || from == to { continue; diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 58423d252..377597841 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -293,7 +293,13 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI } let t = Tokens::get(ui.ctx()); let ids = sel.to_vec(); - let list = common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() }); + // Regions selected in vector edit mode: their own fills ("you can adjust the fill for each + // region individually", Edit vector layers, #1875); the fill commands edit those too. + let region_list = match sel { + [id] if fills => figcraft_engine::vectoredit::selected_region_fills(&app.session, *id), + _ => None, + }; + let list = region_list.unwrap_or_else(|| common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() })); let title = if fills { tr("Fill") } else { tr("Stroke") }; let empty = list.as_ref().is_none_or(|l| l.is_empty()); let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke }; diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 5fbcc7b40..d8a5357f6 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -92,6 +92,13 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { }, PickerTarget::Paint { fills, index, ids } => { let n = doc.node(*ids.first()?)?; + // Regions selected in vector edit mode: the picker edits their own fills (#1875). + if *fills + && ids.len() == 1 + && let Some(Some(list)) = figcraft_engine::vectoredit::selected_region_fills(&app.session, n.id) + { + return list.get(*index).cloned(); + } let list = if *fills { &n.fills } else { &n.strokes }; list.get(*index).cloned() } diff --git a/docs/mcp.md b/docs/mcp.md index b5e49e749..72815b351 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -723,7 +723,10 @@ made active), add?}`, `vector.edit.selectAll` (every edited layer's points), `ve `vector.edit.addVertex`, `vector.edit.connect {a, b}`, `vector.edit.pen {x, y, dx?, dy?}` (continues from the pen's open end; clicking a point connects or branches), `vector.edit.penEnd`, `vector.edit.paint {x, y} | {points}` (paint bucket: fills or unfills the closed area under a page -point; with `points`, the areas under the rest go the way the first went, like a drag), +point; with `points`, the areas under the rest go the way the first went, like a drag), `fill.set`, +`fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer while regions are +selected (`vector.edit.select {regions}`), which edit those regions' own fills and leave the +layer's (`regions: [index, ...]` names them on any vector layer), `vector.edit.cut {segment, t} | {vertex} | {from: [x, y], to: [x, y]}` (the last cuts every path the page line crosses; parts that come apart move to layers of their own above, listed in `layers`), `vector.edit.erase {points, radius?}` (the eraser), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1d3c01373..fa242b184 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (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 (#1875); Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) 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: @@ -86,7 +86,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]` (#1875). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -177,6 +177,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer ("If there are multiple closed regions within a vector network, you can adjust the fill for each region individually", Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | | 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` | | 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. | From d2e7c49e57271daab2d8e7b002ce45ad352531cf Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 09:14:55 -0500 Subject: [PATCH 12/17] Region fills: each region once, no override for a no-op, Copy fill reads the region, layer-only controls off region rows Named regions are deduplicated; an inheriting region stays inheriting when an edit leaves its stack as it was, and its own stack is only replaced once the edit succeeds; fill.copy reads the selected regions' paint; the Fill section leaves the fill style, a variable bound to the layer's fill and the image crop off region rows. Tests in regions_tests and region_fill_rows_tests. Co-Authored-By: Claude Opus 5.5 --- .../src/canvas/vectoredit/regions_tests.rs | 24 +++++ crates/engine/src/cmd/edit/props.rs | 29 ++++-- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/right/paints.rs | 28 +++--- crates/ui-egui/src/region_fill_rows_tests.rs | 99 +++++++++++++++++++ docs/mcp.md | 3 +- docs/ui-parity.md | 2 +- 7 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/region_fill_rows_tests.rs diff --git a/crates/engine/src/canvas/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs index c0b762aff..cc40ef6f0 100644 --- a/crates/engine/src/canvas/vectoredit/regions_tests.rs +++ b/crates/engine/src/canvas/vectoredit/regions_tests.rs @@ -587,3 +587,27 @@ fn the_fill_commands_edit_the_selected_regions() { s.undo().unwrap(); assert_eq!(layer(&s), before, "one undo step per command"); } + +/// Region fill edits from review: a region named twice is edited once; an edit that changes +/// nothing leaves an inheriting region inheriting; Copy fill reads the selected region's fill. +#[test] +fn region_fill_edits_apply_once_skip_no_ops_and_copy_the_region() { + let mut s = Session::new(); + let id = editing_vector(&mut s, overlapping()); + let fills_of = |s: &Session, r: usize| net(s, id).regions[r].fills.clone(); + ex(&mut s, "vector.edit.select", json!({"regions": [1]})); + ex(&mut s, "fill.move", json!({"ids": [id], "from": 0, "to": 0})); + assert_eq!(fills_of(&s, 1), None, "moving a paint onto itself leaves the region inheriting"); + ex(&mut s, "fill.remove", json!({"ids": [id], "index": 5})); + assert_eq!(fills_of(&s, 1), None, "removing a paint that isn't there leaves it inheriting"); + ex(&mut s, "fill.add", json!({"ids": [id], "regions": [0, 0], "color": "#00FF00"})); + assert_eq!(fills_of(&s, 0).map(|f| f.len()), Some(2), "the layer's fill and one added, once"); + // Copy fill takes the selected region's own paint, not the layer's. + ex(&mut s, "fill.set", json!({"ids": [id], "color": "#FF0000"})); + ex(&mut s, "fill.copy", json!({"ids": [id], "index": 0})); + let copied = s.clipboard.paint.clone().map(|(_, p)| p.solid_color().unwrap().to_css_hex().to_ascii_uppercase()); + assert_eq!(copied.as_deref(), Some("#FF0000")); + // Regions whose fills differ have no one paint to copy. + ex(&mut s, "vector.edit.select", json!({"regions": [0, 1]})); + assert!(s.execute("fill.copy", &json!({"ids": [id], "index": 0})).is_err()); +} diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index f11c228cb..04315bbed 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -273,7 +273,7 @@ pub(crate) fn fill_regions(s: &Session, cmd: &str, p: &Value, ids: &[NodeId]) -> if let Some(list) = get(p, "regions") { let [id] = ids else { return Err(bad(cmd, "`regions` goes with one vector layer")) }; let Some(count) = region_count(*id) else { return Err(bad(cmd, "`regions` goes with a vector layer")) }; - let regions = list + let mut regions = list .as_array() .ok_or_else(|| bad(cmd, "regions: [index, ...]"))? .iter() @@ -284,6 +284,9 @@ pub(crate) fn fill_regions(s: &Session, cmd: &str, p: &Value, ids: &[NodeId]) -> .ok_or_else(|| bad(cmd, format!("regions: indices below {count}"))) }) .collect::>>()?; + // A region named twice is edited once. + regions.sort_unstable(); + regions.dedup(); if regions.is_empty() { return Err(bad(cmd, "regions: at least one")); } @@ -292,12 +295,16 @@ pub(crate) fn fill_regions(s: &Session, cmd: &str, p: &Value, ids: &[NodeId]) -> let [id] = ids else { return Ok(None) }; let Some(ve) = s.ds()?.vector_edit.as_ref().filter(|ve| ve.id == *id && !ve.regions.is_empty()) else { return Ok(None) }; let count = region_count(*id).unwrap_or(0); - let regions: Vec = ve.regions.iter().copied().filter(|r| *r < count).collect(); + let mut regions: Vec = ve.regions.iter().copied().filter(|r| *r < count).collect(); + regions.sort_unstable(); + regions.dedup(); Ok((!regions.is_empty()).then_some((*id, regions))) } /// Runs `f` on the own fill stack of each of `regions` of vector `id` (a region without one -/// starts from the layer's fills, which stay as they are). +/// starts from the layer's fills, which stay as they are). A region that inherits the layer's +/// fills keeps inheriting when `f` leaves the stack as it was, so later edits of the layer's +/// fills still reach it; a region's own stack is only replaced once `f` succeeds. pub(crate) fn edit_region_fills( doc: &mut figcraft_doc::Document, id: NodeId, @@ -308,9 +315,11 @@ pub(crate) fn edit_region_fills( let NodeKind::Vector { network } = &mut doc.get_mut(id)?.kind else { return Err(bad("fill", "not a vector layer")) }; for &r in regions { let Some(region) = network.regions.get_mut(r) else { continue }; - let mut list = region.fills.take().unwrap_or_else(|| inherited.clone()); + let mut list = region.fills.clone().unwrap_or_else(|| inherited.clone()); f(&mut list)?; - region.fills = Some(list); + if region.fills.is_some() || list != inherited { + region.fills = Some(list); + } } Ok(()) } @@ -433,7 +442,15 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result list, + Some(None) => return Err(bad(cmd, "the selected regions' fills differ")), + None if fills => &n.fills, + None => &n.strokes, + }; let at = at.unwrap_or(list.len().saturating_sub(1)); let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; (paint, (!fills).then(|| n.stroke.clone())) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9969c106..d787fb1ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -221,6 +221,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod region_fill_rows_tests; +#[cfg(test)] mod resources_tests; #[cfg(test)] mod sampled_color_tests; diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 377597841..675becc6d 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -299,20 +299,20 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI [id] if fills => figcraft_engine::vectoredit::selected_region_fills(&app.session, *id), _ => None, }; + // The fill style, a variable bound to the layer's fill and the image crop are the layer's: + // they stay off a region's rows. + let region = region_list.is_some(); let list = region_list.unwrap_or_else(|| common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() })); let title = if fills { tr("Fill") } else { tr("Stroke") }; let empty = list.as_ref().is_none_or(|l| l.is_empty()); let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke }; // The section's right-click menu adds the copied paint (`fill.paste` / `stroke.paste`). let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") }; - let (clicked, header) = widgets::section_header_resp( - ui, - title, - &[(Icon::Styles, tr("Styles")), (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") })], - empty, - ); + let add = (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") }); + let buttons = if region { vec![add] } else { vec![(Icon::Styles, tr("Styles")), add] }; + let (clicked, header) = widgets::section_header_resp(ui, title, &buttons, empty); if let Some(i) = clicked { - if i == 1 { + if buttons.get(i).is_some_and(|(icon, _)| *icon == Icon::Plus) { // The new paint goes on top; open its picker. A mixed selection has no one index to edit. if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some() && let Some(l) = &list @@ -329,14 +329,16 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI ui.close(); } }); - style_picker::chip(app, ui, doc, &ids, slot); + if !region { + style_picker::chip(app, ui, doc, &ids, slot); + } match &list { None => widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary), Some(l) => { // The last paint is on top: shown first. Rows drag to reorder. let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" }); for (i, p) in l.iter().enumerate().rev() { - let r = paint_row(app, ui, &ids, i, p, fills); + let r = paint_row(app, ui, &ids, i, p, fills, region); drag.row(ui, i, r, r, || paint_label(p)); } if let Some((from, to)) = drag.finish(ui) { @@ -446,7 +448,9 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI } /// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area. -fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool) -> Rect { +/// One paint's row; `region`: a row of the regions selected in vector edit mode (#1875), which +/// leaves out what belongs to the layer (its variable binding, the image crop). +fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool, region: bool) -> Rect { let t = Tokens::get(ui.ctx()); let ids = ids.to_vec(); let cmd = if fills { "fill.set" } else { "stroke.set" }; @@ -461,7 +465,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); // The variable bound to this paint's colour (its key on the layers: `fill` or `fills//color`). - let bound = app.session.doc().ok().and_then(|d| { + let bound = app.session.doc().ok().filter(|_| !region).and_then(|d| { let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?; Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?)) }); @@ -502,7 +506,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, _ => { child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text); // One layer's image fill: crop it on the canvas. - if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) { + if fills && ids.len() == 1 && !region && matches!(p.kind, PaintKind::Image(_)) { let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); let cresp = ui.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image")); icons::paint(ui.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary }); diff --git a/crates/ui-egui/src/region_fill_rows_tests.rs b/crates/ui-egui/src/region_fill_rows_tests.rs new file mode 100644 index 000000000..7dc1c9a5b --- /dev/null +++ b/crates/ui-egui/src/region_fill_rows_tests.rs @@ -0,0 +1,99 @@ +//! The Fill section with a region of a vector selected in edit mode (#1875), driven through egui +//! headless: the region's own fill shows, and what belongs to the layer (its fill style, a +//! variable bound to its fill) stays off the region's rows. + +use egui::accesskit::Role; +use egui::{Pos2, pos2}; +use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// Four points and four straight segments of a square, appended to `n`. +fn square(n: &mut VectorNetwork, x0: f64, y0: f64, size: f64) { + let v0 = n.vertices.len(); + for (x, y) in [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size)] { + n.vertices.push(Vertex::new(x, y)); + } + for k in 0..4 { + n.segments.push(Segment::line(v0 + k, v0 + (k + 1) % 4)); + } +} + +/// A vector of two squares, the outer one's region then the inner one's, its fill bound to a +/// blue colour variable; in edit mode with the inner region selected and given a red fill of its +/// own. +fn ui() -> Ui { + let mut s = Session::new(); + let r = s.execute("vector.create", &json!({"points": [[0, 0], [10, 0], [10, 10]], "closed": true})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + { + let doc = s.doc_mut().unwrap(); + let node = doc.get_mut(id).unwrap(); + let mut n = VectorNetwork::default(); + square(&mut n, 0.0, 0.0, 200.0); + square(&mut n, 60.0, 60.0, 80.0); + n.regions = + vec![Region { loops: vec![vec![0, 1, 2, 3]], ..Default::default() }, Region { loops: vec![vec![4, 5, 6, 7]], ..Default::default() }]; + node.kind = NodeKind::Vector { network: n }; + node.transform = figcraft_geom::Affine::IDENTITY; + node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; + figcraft_engine::vectoredit::refit(doc, id).unwrap(); + } + s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "brand", "type": "color", "value": "#0000FF"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [id.0], "field": "fill", "variable": "brand"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("vector.edit.begin", &json!({"id": id.0})).unwrap(); + s.execute("vector.edit.select", &json!({"regions": [1]})).unwrap(); + s.execute("fill.set", &json!({"ids": [id.0], "color": "#FF0000"})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + u.frame(vec![]); + u +} + +/// The accessible controls after a frame: role, label, value and centre. +fn controls(u: &mut Ui) -> Vec<(Role, String, String, Pos2)> { + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + nodes + .iter() + .map(|(_, n)| { + let at = n.bounds().map_or(pos2(0.0, 0.0), |b| pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)); + (n.role(), n.label().unwrap_or_default().to_string(), n.value().unwrap_or_default().to_string(), at) + }) + .collect() +} + +fn button(c: &[(Role, String, String, Pos2)], name: &str) -> Pos2 { + c.iter().find(|(r, l, _, _)| *r == Role::Button && l == name).map(|(_, _, _, p)| *p).unwrap_or_else(|| panic!("no {name} button: {c:?}")) +} + +/// What the Fill section shows, between its + and the Stroke section's: its hex rows, whether a +/// Detach variable button is there, and whether its header has a Styles button. +fn fill_section(c: &[(Role, String, String, Pos2)]) -> (Vec, bool, bool) { + let (top, bottom) = (button(c, "Add fill"), button(c, "Add stroke")); + let inside = |p: &Pos2| p.y > top.y + 2.0 && p.y < bottom.y - 2.0; + let rows = c.iter().filter(|(r, _, _, p)| *r == Role::TextInput && inside(p)).map(|(_, _, v, _)| v.clone()).collect(); + let detach = c.iter().any(|(_, l, _, p)| l == "Detach variable" && inside(p)); + let styles = c.iter().any(|(r, l, _, p)| *r == Role::Button && l == "Styles" && (p.y - top.y).abs() < 2.0); + (rows, detach, styles) +} + +#[test] +fn a_selected_regions_rows_leave_out_the_layers_style_and_variable() { + let mut u = ui(); + let red = figcraft_color::Color::from_hex("#FF0000").unwrap().to_hex(); + let (rows, detach, styles) = fill_section(&controls(&mut u)); + assert_eq!(rows, [red], "the region's own red, as a hex field"); + assert!(!detach, "the layer's variable binding is not the region's"); + assert!(!styles, "no fill style for a region's fills"); + // No region selected: the layer's row again, with its bound variable and the Styles button. + u.app.run("vector.edit.select", json!({})); + let (rows, detach, styles) = fill_section(&controls(&mut u)); + assert!(rows.is_empty(), "the layer's paint shows as its variable, not a hex field: {rows:?}"); + assert!(detach && styles); +} diff --git a/docs/mcp.md b/docs/mcp.md index 72815b351..c014dc877 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -726,7 +726,8 @@ from the pen's open end; clicking a point connects or branches), `vector.edit.pe point; with `points`, the areas under the rest go the way the first went, like a drag), `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer while regions are selected (`vector.edit.select {regions}`), which edit those regions' own fills and leave the -layer's (`regions: [index, ...]` names them on any vector layer), +layer's (`regions: [index, ...]` names them on any vector layer; `fill.copy` copies the selected +regions' paint), `vector.edit.cut {segment, t} | {vertex} | {from: [x, y], to: [x, y]}` (the last cuts every path the page line crosses; parts that come apart move to layers of their own above, listed in `layers`), `vector.edit.erase {points, radius?}` (the eraser), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fa242b184..4a9168cb8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -86,7 +86,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]` (#1875). Not yet: areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, and what belongs to the layer (its fill style, a variable bound to its fill, the image crop) stays off a region's rows (#1875). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | From aa2fed07e270cb02153f052b240f06b6a46fdd74 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 09:49:10 -0500 Subject: [PATCH 13/17] Region fills: fill.pattern stays on the layer; the canvas handles and the picker's image paths edit the region's paint fill.pattern sets the layer's fill as its spec says. The gradient and crop handles read the paint from the stack fill.set writes (the selected regions' while there are any); for a region's paint, Upload from computer and image presets write the paint like any edit, and a drop on the picker goes to the canvas (image.replace and image.adjust edit layer paints). Tests in regions_tests, canvas/handles/region_tests and colorpicker image tests. Co-Authored-By: Claude Opus 5.5 --- .../src/canvas/vectoredit/regions_tests.rs | 13 +++++++ crates/engine/src/cmd/edit/props.rs | 10 ++++-- crates/ui-egui/src/canvas/handles.rs | 24 ++++++++++--- .../src/canvas/handles/region_tests.rs | 35 +++++++++++++++++++ crates/ui-egui/src/region_fill_rows_tests.rs | 16 ++++++--- crates/ui-egui/src/widgets/colorpicker.rs | 27 +++++++++++--- .../src/widgets/colorpicker_image_tests.rs | 28 +++++++++++++++ docs/ui-parity.md | 2 +- 8 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/canvas/handles/region_tests.rs diff --git a/crates/engine/src/canvas/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs index cc40ef6f0..272c556fd 100644 --- a/crates/engine/src/canvas/vectoredit/regions_tests.rs +++ b/crates/engine/src/canvas/vectoredit/regions_tests.rs @@ -611,3 +611,16 @@ fn region_fill_edits_apply_once_skip_no_ops_and_copy_the_region() { ex(&mut s, "vector.edit.select", json!({"regions": [0, 1]})); assert!(s.execute("fill.copy", &json!({"ids": [id], "index": 0})).is_err()); } + +/// `fill.pattern` sets the layer's fill, as its spec says, also while regions are selected. +#[test] +fn fill_pattern_sets_the_layers_fill_with_regions_selected() { + let mut s = Session::new(); + let src = crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 20, "height": 20}))["id"]).unwrap(); + let id = editing_vector(&mut s, overlapping()); + ex(&mut s, "vector.edit.select", json!({"regions": [1]})); + ex(&mut s, "fill.pattern", json!({"ids": [id], "source": src})); + assert_eq!(net(&s, id).regions[1].fills, None, "the region still inherits"); + let top = s.doc().unwrap().node(id).unwrap().fills.last().cloned().unwrap(); + assert!(matches!(top.kind, figcraft_color::PaintKind::Pattern(_)), "{top:?}"); +} diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index 04315bbed..2a719af0d 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -369,6 +369,12 @@ pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> } pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { + paint_set_in(s, p, fills, true) +} + +/// [`paint_set`]; `to_regions`: whether the regions selected (or named) on a vector take the +/// edit in their own fills (#1875), as for `fill.set`; `fill.pattern` always sets the layer's. +fn paint_set_in(s: &mut Session, p: &Value, fills: bool, to_regions: bool) -> Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; let idx = opt_index(cmd, p, "index")?; @@ -400,7 +406,7 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result Result { q["paint"] = serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) }) .map_err(|e| bad(CMD, e.to_string()))?; - paint_set(s, &q, true) + paint_set_in(s, &q, true, false) } pub(super) fn effect_add(s: &mut Session, p: &Value) -> Result { diff --git a/crates/ui-egui/src/canvas/handles.rs b/crates/ui-egui/src/canvas/handles.rs index 137e2c92b..aa3033b0d 100644 --- a/crates/ui-egui/src/canvas/handles.rs +++ b/crates/ui-egui/src/canvas/handles.rs @@ -40,6 +40,17 @@ enum Drag { /// The Crop-mode image being edited: (node, fills, index, paint, image rect in the unit box). type UnitRect = (f64, f64, f64, f64); +/// The paint list a picker paint at an index belongs to on layer `n`: the selected regions' own +/// fills while regions of it are selected in vector edit mode, as the Fill section lists them and +/// `fill.set` edits them (#1875), else the layer's fills or strokes. None when those regions' +/// fills differ. +fn edited_list(app: &FigApp, n: &figcraft_doc::Node, fills: bool) -> Option> { + if fills && let Some(regions) = figcraft_engine::vectoredit::selected_region_fills(&app.session, n.id) { + return regions; + } + Some(if fills { n.fills.clone() } else { n.strokes.clone() }) +} + /// The paint open in the colour picker, else the image fill in crop mode (while its layer is the /// selection). fn crop_target(app: &FigApp) -> Option<(NodeId, bool, usize, ImagePaint, UnitRect)> { @@ -53,7 +64,7 @@ fn crop_target(app: &FigApp) -> Option<(NodeId, bool, usize, ImagePaint, UnitRec }; let doc = app.session.doc().ok()?; let n = doc.node(id)?; - let list = if fills { &n.fills } else { &n.strokes }; + let list = edited_list(app, n, fills)?; let PaintKind::Image(img) = &list.get(index)?.kind else { return None }; if img.scale_mode != ImageScaleMode::Crop { return None; @@ -193,7 +204,7 @@ fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color: return None; } let n = app.session.doc().ok()?.node(id)?; - let list = if *fills { &n.fills } else { &n.strokes }; + let list = edited_list(app, n, *fills)?; match &list.get(*index)?.kind { PaintKind::Gradient(g) => Some((id, *fills, *index, g.clone())), _ => None, @@ -233,7 +244,7 @@ fn line_param(a: Pos2, b: Pos2, p: Pos2) -> Option<(f64, f32)> { fn set_gradient(app: &mut FigApp, id: NodeId, fills: bool, index: usize, g: figcraft_color::Gradient) { let Some(n) = app.session.doc().ok().and_then(|d| d.node(id)).cloned() else { return }; - let list = if fills { &n.fills } else { &n.strokes }; + let Some(list) = edited_list(app, &n, fills) else { return }; let Some(mut paint) = list.get(index).cloned() else { return }; paint.kind = PaintKind::Gradient(g); let cmd = if fills { "fill.set" } else { "stroke.set" }; @@ -652,7 +663,7 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) { Drag::Gradient { id, fills, index, which } => { let Some(u) = screen_to_unit(app, *id, pos) else { return }; let Some(n) = app.session.doc().ok().and_then(|d| d.node(*id)).cloned() else { return }; - let list = if *fills { &n.fills } else { &n.strokes }; + let Some(list) = edited_list(app, &n, *fills) else { return }; let Some(mut paint) = list.get(*index).cloned() else { return }; if let PaintKind::Gradient(g) = &mut paint.kind { match which { @@ -727,7 +738,7 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) { (ax.min(ax + nw), ay.min(ay + nh), ax.max(ax + nw), ay.max(ay + nh)) }; let Some(n) = app.session.doc().ok().and_then(|d| d.node(*id)).cloned() else { return }; - let list = if *fills { &n.fills } else { &n.strokes }; + let Some(list) = edited_list(app, &n, *fills) else { return }; let Some(mut paint) = list.get(*index).cloned() else { return }; if let PaintKind::Image(img) = &mut paint.kind { img.crop = Some(ImagePaint::crop_from_rect(r.0, r.1, r.2, r.3)); @@ -749,3 +760,6 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) { } } } + +#[cfg(test)] +mod region_tests; diff --git a/crates/ui-egui/src/canvas/handles/region_tests.rs b/crates/ui-egui/src/canvas/handles/region_tests.rs new file mode 100644 index 000000000..d5033d7f7 --- /dev/null +++ b/crates/ui-egui/src/canvas/handles/region_tests.rs @@ -0,0 +1,35 @@ +//! The canvas paint handles with a region of a vector selected in edit mode (#1875): they read +//! and write the paint the colour picker shows, the region's own, not the layer's. + +use figcraft_color::{Color, Gradient, GradientKind, Paint, PaintKind}; +use serde_json::json; + +use super::{gradient_target, set_gradient}; +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; + +#[test] +fn the_gradient_handles_edit_a_selected_regions_own_paint() { + let mut s = figcraft_engine::Session::new(); + let id = crate::region_fill_rows_tests::two_region_vector(&mut s); + s.execute("vector.edit.begin", &json!({"id": id.0})).unwrap(); + s.execute("vector.edit.select", &json!({"regions": [1]})).unwrap(); + // The region's own paint: a gradient at half opacity; the layer keeps its solid grey. + let mut paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)); + paint.opacity = 0.5; + s.execute("fill.set", &json!({"ids": [id.0], "paint": paint})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![id] }); + let (at, fills, index, g) = gradient_target(&u.app).expect("the handles show the region's gradient"); + assert_eq!((at, fills, index), (id, true, 0)); + let mut moved = g.clone(); + moved.from = (0.1, 0.2); + set_gradient(&mut u.app, id, true, 0, moved); + let doc = u.app.session.doc().unwrap(); + let n = doc.node(id).unwrap(); + let figcraft_doc::NodeKind::Vector { network } = &n.kind else { panic!("not a vector") }; + let region = network.regions[1].fills.clone().unwrap(); + assert!(matches!(®ion[0].kind, PaintKind::Gradient(x) if x.from == (0.1, 0.2)), "{region:?}"); + assert_eq!(region[0].opacity, 0.5, "the region's paint keeps its own opacity"); + assert!(matches!(n.fills[0].kind, PaintKind::Solid { .. }), "the layer's fill is untouched: {:?}", n.fills); +} diff --git a/crates/ui-egui/src/region_fill_rows_tests.rs b/crates/ui-egui/src/region_fill_rows_tests.rs index 7dc1c9a5b..5d071bca3 100644 --- a/crates/ui-egui/src/region_fill_rows_tests.rs +++ b/crates/ui-egui/src/region_fill_rows_tests.rs @@ -21,11 +21,9 @@ fn square(n: &mut VectorNetwork, x0: f64, y0: f64, size: f64) { } } -/// A vector of two squares, the outer one's region then the inner one's, its fill bound to a -/// blue colour variable; in edit mode with the inner region selected and given a red fill of its -/// own. -fn ui() -> Ui { - let mut s = Session::new(); +/// A vector of two squares on the page, the outer one's region then the inner one's, both +/// inheriting the layer's default grey fill (also used by the canvas handle and picker tests). +pub(crate) fn two_region_vector(s: &mut Session) -> NodeId { let r = s.execute("vector.create", &json!({"points": [[0, 0], [10, 0], [10, 10]], "closed": true})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); { @@ -41,6 +39,14 @@ fn ui() -> Ui { node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; figcraft_engine::vectoredit::refit(doc, id).unwrap(); } + id +} + +/// [`two_region_vector`] with its fill bound to a blue colour variable; in edit mode with the +/// inner region selected and given a red fill of its own. +fn ui() -> Ui { + let mut s = Session::new(); + let id = two_region_vector(&mut s); s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); s.execute("variable.create", &json!({"collection": "Theme", "name": "brand", "type": "color", "value": "#0000FF"})).unwrap(); s.execute("variable.bind", &json!({"ids": [id.0], "field": "fill", "variable": "brand"})).unwrap(); diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index d8a5357f6..60c1e0b32 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -581,6 +581,18 @@ fn set_image_pending(ctx: &egui::Context, target: Option<&PickerTarget>) { } /// `image.replace`'s parameters for layers' paint `index`. +/// Whether `target` is a paint of the regions selected in vector edit mode (#1875): the picker +/// shows the regions' own fills, which the image commands (layer paints only) don't reach, so +/// their image edits are written as paints like any other. +fn region_backed(app: &FigApp, target: &PickerTarget) -> bool { + match target { + PickerTarget::Paint { fills: true, ids, .. } => { + matches!(ids.as_slice(), [id] if figcraft_engine::vectoredit::selected_region_fills(&app.session, *id).is_some()) + } + _ => false, + } +} + fn image_params(fills: bool, index: usize, ids: &[NodeId]) -> serde_json::Value { json!({"ids": ids, "target": if fills { "fill" } else { "stroke" }, "index": index}) } @@ -591,6 +603,11 @@ fn image_params(fills: bool, index: usize, ids: &[NodeId]) -> serde_json::Value pub(crate) fn image_drop_target(app: &FigApp, ctx: &egui::Context) -> Option<(Rect, serde_json::Value)> { let target = app.ui.picker.as_ref()?; let PickerTarget::Paint { fills, index, ids } = target else { return None }; + // A region's own paint takes no drop (`image.replace` edits layer paints); the file goes to + // the canvas as it would without the picker. + if region_backed(app, target) { + return None; + } if !image_pending(ctx, target) && !matches!(current_paint(app, target)?.kind, PaintKind::Image(_)) { return None; } @@ -611,7 +628,9 @@ fn picked_bytes(file: &serde_json::Value) -> Option> { fn upload_image(app: &mut FigApp, target: &PickerTarget) { let target = target.clone(); app.pick_file(tr("Choose image"), &IMAGE_FILES, true, move |app, file| { - if let PickerTarget::Paint { fills, index, ids } = &target { + if let PickerTarget::Paint { fills, index, ids } = &target + && !region_backed(app, &target) + { let mut params = image_params(*fills, *index, ids); for key in ["path", "data"] { if let Some(v) = file.get(key) { @@ -1404,9 +1423,9 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m app.run("image.deletePreset", json!({"name": name})); } Some(PresetAction::Apply(name)) => match target { - // Layer paints go through the command; other targets (a text range, a style) get the - // preset's values written like any edit. - PickerTarget::Paint { fills, index, ids } => { + // Layer paints go through the command; other targets (a text range, a style, a + // region's own paint) get the preset's values written like any edit. + PickerTarget::Paint { fills, index, ids } if !region_backed(app, target) => { app.run("image.adjust", json!({"ids": ids, "target": if *fills { "fill" } else { "stroke" }, "index": index, "preset": name})); } _ => { diff --git a/crates/ui-egui/src/widgets/colorpicker_image_tests.rs b/crates/ui-egui/src/widgets/colorpicker_image_tests.rs index 539a69fc9..5d4b229a5 100644 --- a/crates/ui-egui/src/widgets/colorpicker_image_tests.rs +++ b/crates/ui-egui/src/widgets/colorpicker_image_tests.rs @@ -284,3 +284,31 @@ fn cancelling_the_upload_changes_nothing() { } let _ = std::fs::remove_dir_all(&dir); } + +/// A region of a vector selected in edit mode (#1875): Upload from computer puts the image in the +/// region's own fill the picker shows, the layer's fills untouched; a file dropped on the picker +/// isn't taken for a region's paint (it goes to the canvas, as without the picker). +#[test] +fn upload_from_computer_on_a_regions_fill_puts_the_image_in_the_region() { + let dir = temp_dir("region"); + let mut s = Session::new(); + let id = crate::region_fill_rows_tests::two_region_vector(&mut s); + s.execute("vector.edit.begin", &json!({"id": id.0})).unwrap(); + s.execute("vector.edit.select", &json!({"regions": [1]})).unwrap(); + s.execute("fill.set", &json!({"ids": [id.0], "color": "#FF0000"})).unwrap(); + let layer_fills = s.doc().unwrap().node(id).unwrap().fills.clone(); + let mut u = Ui::new(s); + open_picker(&mut u, id, 0); + let asked = fake_picker(&mut u, Some(png(5)), false, &dir); + click_image_tab(&mut u); + assert!(super::image_drop_target(&u.app, &u.ctx).is_none(), "a drop isn't taken for a region's paint"); + click_upload(&mut u); + assert_eq!(asked.load(Ordering::SeqCst), 1); + let doc = u.app.session.doc().unwrap(); + let n = doc.node(id).unwrap(); + assert_eq!(n.fills, layer_fills, "the layer's fills are untouched"); + let figcraft_doc::NodeKind::Vector { network } = &n.kind else { panic!("not a vector") }; + let region = network.regions[1].fills.clone().unwrap(); + assert!(matches!(region.last().map(|p| &p.kind), Some(PaintKind::Image(_))), "{region:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4a9168cb8..b9972b7aa 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -86,7 +86,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, and what belongs to the layer (its fill style, a variable bound to its fill, the image crop) stays off a region's rows (#1875). Not yet: areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | From a3cadb66cf08fa17309ad8e46dc74dd4c7c59a65 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 01:12:47 -0500 Subject: [PATCH 14/17] Docs: one vector edit row after the merge The merge with main kept both versions of the row; they are combined, the Bend click sentence and the region fill sentences together. Co-Authored-By: Claude Opus 5.5 --- docs/ui-parity.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0f7119979..69599e0bc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -87,8 +87,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875). Not yet: areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | From 45ef37fc610efaddc19207ed661e13862e7ee4fc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 11:07:35 +0700 Subject: [PATCH 15/17] Docs: one row per item after the merge --- docs/ui-parity.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9963c9019..205f73f79 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -188,7 +188,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. | | 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. | | 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) | -| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. | | 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. | From 16ae1b22736e8a690ae85d95a86275f5c81282db Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 11:10:41 +0700 Subject: [PATCH 16/17] Region fills: say it in our own words; one row per item after the merge --- crates/engine/src/canvas/vectoredit.rs | 3 +-- crates/engine/src/canvas/vectoredit/regions_tests.rs | 3 +-- crates/engine/src/cmd/edit/props.rs | 4 ++-- crates/ui-egui/src/panels/right/paints.rs | 3 +-- docs/ui-parity.md | 1 - 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index cb654eebc..4f265e0f5 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -1264,8 +1264,7 @@ pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result /// The fills the Fill section shows for layer `id` while regions of it are selected in edit /// mode: the stack those regions share (their own, else the layer's), `Some(None)` when they /// differ (mixed), `None` when no region of that layer is selected (the layer's fills show). The -/// fill commands edit the same regions then ("you can adjust the fill for each region -/// individually", Edit vector layers, #1875). +/// fill commands edit the same regions then (#1875). pub fn selected_region_fills(s: &Session, id: NodeId) -> Option>> { let ve = s.ds().ok()?.vector_edit.as_ref().filter(|ve| ve.id == id && !ve.regions.is_empty())?; let doc = s.doc().ok()?; diff --git a/crates/engine/src/canvas/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs index 272c556fd..a12fc0f84 100644 --- a/crates/engine/src/canvas/vectoredit/regions_tests.rs +++ b/crates/engine/src/canvas/vectoredit/regions_tests.rs @@ -542,8 +542,7 @@ fn hovered_and_selected_is_hatched_once_and_regions_share_one_pattern() { assert!(part.iter().all(|x| all.iter().any(|y| (x - y).abs() < 1e-6))); } -/// "If there are multiple closed regions within a vector network, you can adjust the fill for each -/// region individually" (Edit vector layers, #1875): with regions selected in edit mode, the fill +/// Each closed region of a vector network has its own fill (#1875): with regions selected in edit mode, the fill /// commands edit those regions' own fills and leave the layer's; `regions` names them outright. #[test] fn the_fill_commands_edit_the_selected_regions() { diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index 737a48773..fb8a1c17f 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -262,8 +262,8 @@ pub(super) fn reparent(s: &mut Session, p: &Value) -> Result { /// The regions of a vector whose own fill stacks a fill command edits instead of the layer's: the /// `regions` named (with one target, a vector; indices into its network's regions), else the -/// regions selected in vector edit mode when the one target is the layer being edited ("you can -/// adjust the fill for each region individually", Edit vector layers, #1875). None: the layer's. +/// regions selected in vector edit mode when the one target is the layer being edited (#1875). +/// None: the layer's. pub(crate) fn fill_regions(s: &Session, cmd: &str, p: &Value, ids: &[NodeId]) -> Result)>> { let doc = s.doc()?; let region_count = |id: NodeId| match doc.node(id).map(|n| &n.kind) { diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index bb783c4ea..967ae266b 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -293,8 +293,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI } let t = Tokens::get(ui.ctx()); let ids = sel.to_vec(); - // Regions selected in vector edit mode: their own fills ("you can adjust the fill for each - // region individually", Edit vector layers, #1875); the fill commands edit those too. + // Regions selected in vector edit mode: their own fills (#1875); the fill commands edit those too. let region_list = match sel { [id] if fills => figcraft_engine::vectoredit::selected_region_fills(&app.session, *id), _ => None, diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 856f6a4ff..a340a3078 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -191,7 +191,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. | | 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer ("If there are multiple closed regions within a vector network, you can adjust the fill for each region individually", Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. | -| 2026-11-02 | patch | Fill section: a text whose characters were coloured differently while editing shows mixed content, as a mixed selection does ("Text nodes can have multiple sets of fills if some characters are colored differently than others", the developer documentation's `fills`); characters that all share fills the layer doesn't have (all of them styled while editing) show those instead of the layer's older list; and a layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, their own fills going in the same undo step. The section used to show the layer's own fill, a single colour the canvas no longer showed (#1856). Tests: `text_mixed_fill_tests`, engine `text_fill_tests`. Numbers unchanged | | 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. | | 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | From 184452c9b305fe25dff30008bf7123523d5d4cf4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 11:12:40 +0700 Subject: [PATCH 17/17] Docs: region fills row in our own words --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a340a3078..80e074726 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -190,7 +190,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. | -| 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer ("If there are multiple closed regions within a vector network, you can adjust the fill for each region individually", Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. | +| 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer (Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. | | 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. | | 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. |