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 |