Merge pull request #1457 from culiulemon/pr/misc-fixes

feat: property copy/paste, blend-mode hover preview and clipboard fixes
This commit is contained in:
BFlat
2026-10-29 10:47:30 +07:00
committed by GitHub
48 changed files with 703 additions and 74 deletions
+2
View File
@@ -8,3 +8,5 @@ plans/
/log/
.DS_Store
*.profraw
.cache/
.vscode/
+3
View File
@@ -89,3 +89,6 @@ sync_github_name = true
[people.mateusvillain]
sync_github_name = true
[people.culiulemon]
sync_github_name = true
+19 -8
View File
@@ -1,9 +1,9 @@
//! Live text previews: what a text command would do, shown on the canvas without an undo step —
//! while the pointer is over a font family or style, a text style in the style browser or a
//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes
//! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so
//! committing (running the command for real) applies to the document itself as one undo step, and
//! Escape just ends the preview.
//! Live previews: what a command would do, shown on the canvas without an undo step — while the
//! pointer is over a font family or style, a text style in the style browser, a font's named
//! instance or a blend mode, or while a variable font axis slider is dragged. The document under
//! the preview is kept and comes back when the preview ends ([`end`], `text.endPreview`). Every
//! other command ends it first, so committing (running the command for real) applies to the
//! document itself as one undo step, and Escape just ends the preview.
use std::sync::Arc;
@@ -14,8 +14,19 @@ use crate::{EngineError, Result, Session, bad, find_command, guard, normalize};
/// The commands that show or end a preview (they don't end the one showing).
pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"];
/// What can be previewed: text styles, font styles and axis values.
pub const PREVIEWABLE: [&str; 4] = ["style.apply", "library.applyStyle", "text.setVariations", "text.style"];
/// What can be previewed: text styles, font styles and axis values, layers' properties and the
/// blend of a paint on a layer, a selection or a style.
pub const PREVIEWABLE: [&str; 9] = [
"style.apply",
"library.applyStyle",
"text.setVariations",
"text.style",
"node.set",
"fill.set",
"stroke.set",
"selection.setPaint",
"style.update",
];
/// Show `command params` as a live preview (in place of an earlier one).
pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
+3
View File
@@ -422,6 +422,9 @@ pub(crate) enum Gesture {
/// A Shift-press on this selected layer: it leaves the selection on release, unless the
/// press became a drag.
deselect: Option<NodeId>,
/// A press on one layer of a multi-selection that keeps the selection to drag it all: a
/// release without a drag selects just this layer.
keep: Option<NodeId>,
},
Resize {
start: Point,
+19 -4
View File
@@ -94,6 +94,10 @@ impl Session {
let behind_sel = |t: Option<NodeId>| {
t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s)))))
};
// What the press hit (a label or a painted layer): a click that doesn't become a drag
// selects just it. A press that reaches nothing above the selection uses the selection as
// its drag target instead; that fallback is not a clicked layer.
let clicked = target;
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
Some(first)
@@ -102,13 +106,18 @@ impl Session {
};
match target {
Some(target) => {
// Clicking inside the current selection's box keeps it (drag them all).
// Clicking inside the current selection's box keeps it (drag them all); a click
// that doesn't become a drag narrows it to the clicked layer.
let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift;
// Shift on a selected layer takes it out on release if the press doesn't become a
// drag; a drag moves the selection with it. Where no drag can start, it goes now.
let deselect = (ev.shift && sel.contains(&target)).then_some(target);
let without_target = || sel.iter().copied().filter(|x| *x != target).collect::<Vec<_>>();
let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) {
// A release without a drag narrows the selection to the layer the press hit. A
// selected frame's name label sits outside the selection bounds, so it counts too.
let keep = clicked
.filter(|c| (label.is_some() || in_sel_box) && !ev.cmd && (sel.contains(c) || sel.iter().any(|s| doc.is_ancestor(*s, *c))));
let new_sel = if keep.is_some() {
sel.clone()
} else if ev.shift && deselect.is_none() {
let mut s2 = sel.clone();
@@ -120,10 +129,13 @@ impl Session {
vec![target]
};
self.set_selection(new_sel.clone());
// Dev Mode only selects (Shift-click still deselects): nothing moves.
// Dev Mode only selects (Shift-click still deselects): nothing moves. No move
// gesture starts, so a click selects the layer it hit now instead of on release.
if !edits {
if deselect.is_some() {
self.set_selection(without_target());
} else if let Some(k) = keep.filter(|_| sel.len() > 1) {
self.set_selection(vec![k]);
}
return Ok(());
}
@@ -132,6 +144,9 @@ impl Session {
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() {
if deselect.is_some() {
self.set_selection(without_target());
} else if let Some(k) = keep {
// No drag can start here, so the click narrows the selection now.
self.set_selection(vec![k]);
}
return Ok(());
}
@@ -148,7 +163,7 @@ impl Session {
}
return Err(e);
}
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect };
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect, keep };
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
}
None => {
+8 -1
View File
@@ -111,7 +111,7 @@ impl Session {
self.tool.overlay.drop_cell = None;
let drop_target = self.tool.overlay.drop_target.take();
match g {
Gesture::Move { ids, moved, duplicate, start, deselect, .. } => {
Gesture::Move { ids, moved, duplicate, start, deselect, keep, .. } => {
if moved {
// A free move lands in the frame under the pointer (or the page) where it was
// dropped; layers in an auto layout frame already sit in their slot.
@@ -156,6 +156,13 @@ impl Session {
if let Some(d) = deselect {
let sel = self.selection().into_iter().filter(|x| *x != d).collect();
self.set_selection(sel);
} else if let Some(k) = keep {
// A click (not a drag) on one layer of a multi-selection selects just it.
let doc = self.doc()?.clone();
let sel = self.selection();
if sel.len() > 1 && (sel.contains(&k) || sel.iter().any(|s| doc.is_ancestor(*s, k))) {
self.set_selection(vec![k]);
}
}
if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) {
self.widget_click(start, ev.tol() * 2.0)?;
+15 -2
View File
@@ -112,6 +112,8 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
let props = s.clipboard.props.take();
let paint = s.clipboard.paint.take();
let stroke = s.clipboard.stroke.take();
let paint_images = std::mem::take(&mut s.clipboard.paint_images);
let paint_source_doc = s.clipboard.paint_source_doc;
let effect = s.clipboard.effect.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
@@ -121,6 +123,8 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
props,
paint,
stroke,
paint_images,
paint_source_doc,
effect,
bounds,
source_doc: 0,
@@ -294,9 +298,18 @@ pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let mut v = json!({
"fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align,
"effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii,
"cornerSmoothing": n.corners.smoothing,
"strokeSides": n.stroke.sides, "startCap": n.stroke.cap_start, "endCap": n.stroke.cap_end,
"strokeJoin": n.stroke.join, "miterLimit": n.stroke.miter_limit, "dashes": n.stroke.dashes,
"widthProfile": super::super::props::width_points_json(n.stroke.width_profile.as_ref()), "brush": n.stroke.brush,
"effects": n.effects, "opacity": n.opacity, "blend": n.blend,
});
// A vector rounds each anchor on its own (`vector.edit.cornerRadius` keeps the radii in the
// network's vertices, its layer corners stay empty): there is no style radius to carry, and
// pasting the empty one would flatten every anchor's radius.
if !matches!(n.kind, NodeKind::Vector { .. }) {
v["cornerRadius"] = json!(n.corners.radii);
v["cornerSmoothing"] = json!(n.corners.smoothing);
}
if let NodeKind::Text(t) = &n.kind {
v["fontFamily"] = json!(t.style.font_family);
v["fontStyle"] = json!(t.style.font_style);
+40 -2
View File
@@ -346,6 +346,16 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Valu
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()))
};
// An image paint names its data by key: take the data along, so a paste into another file has
// it too. A pattern names a layer in this file, so the file is recorded with the copy.
let mut images = std::collections::BTreeMap::new();
if let figcraft_color::PaintKind::Image(img) = &paint.kind
&& let Some(d) = s.doc()?.images.get(&img.image)
{
images.insert(img.image.clone(), d.clone());
}
s.clipboard.paint_images = images;
s.clipboard.paint_source_doc = s.ds()?.uid;
s.clipboard.paint = Some((fills, paint.clone()));
s.clipboard.stroke = stroke;
serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string()))
@@ -355,7 +365,7 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Valu
/// stroke's copied settings go onto each target too.
pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.paste" } else { "stroke.paste" };
let Some((copied_fills, paint)) = s.clipboard.paint.clone() else {
let Some((copied_fills, mut paint)) = s.clipboard.paint.clone() else {
return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into()));
};
if copied_fills != fills {
@@ -364,13 +374,41 @@ pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Val
}
let ids = req_targets(cmd, s, p)?;
// A copied pattern pointing at a target or a layer inside it would make the layer pattern
// itself; `fill.pattern` refuses the same, and the renderer would drop the pattern.
// itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. Its node id
// only names something in the file it was copied from, where it may be an unrelated layer.
if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind {
if s.ds()?.uid != s.clipboard.paint_source_doc {
return Err(bad(cmd, "a pattern can't be pasted into another file"));
}
let doc = s.doc()?;
if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) {
return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it"));
}
}
// An image paint names its data by key; a copy from another file brings the data with it.
if let figcraft_color::PaintKind::Image(img) = &mut paint.kind {
let copied = img.image.clone();
if let Some(d) = s.clipboard.paint_images.get(&copied).cloned() {
let doc = s.doc_mut()?;
// The destination may already use that key for different data (other layers keep
// that image): the pasted paint takes the copied data's content hash instead. That key
// can be taken too — images are usually keyed by their hash, so the copied key often
// is that hash — and then a numbered variant of it. Image keys become archive member
// names, which keep only letters and digits, so the number is appended plainly.
let base = if doc.images.get(&copied).is_some_and(|existing| existing != &d) { figcraft_format::image_hash(&d.bytes) } else { copied };
let mut key = base.clone();
let mut n = 0u32;
while doc.images.get(&key).is_some_and(|existing| existing != &d) {
n = n.saturating_add(1);
if n > 999 {
return Err(bad(cmd, "no free image key for the copied image"));
}
key = format!("{base}{n}");
}
doc.images.entry(key.clone()).or_insert(d);
img.image = key;
}
}
let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?});
let out = match opt_index(cmd, p, "index")? {
Some(at) => {
+2 -2
View File
@@ -361,8 +361,8 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!(
"text.preview",
"Preview text change",
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
"Preview a change",
"{command: style.apply|library.applyStyle|text.setVariations|text.style|node.set|fill.set|stroke.set|selection.setPaint|style.update, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style, named instance, blend mode, or a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
false,
|s, p| {
let c = req_string("text.preview", p, "command")?;
+15
View File
@@ -520,6 +520,21 @@ mod tests {
assert!(ark.contains(&format!(".width({})", num(width))), "the alignment has a box: {ark}");
}
#[test]
fn arkui_percent_line_height_and_letter_spacing_are_absolute_numbers() {
// ArkUI's `letterSpacing` falls back to the default for a percent string, and `lineHeight`
// takes no percent at all: both are numbers of vp, a percent being of the font size.
let mut s = Session::new();
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap();
let t = id_of(&s.execute("text.create", &json!({"text": "Hi", "x": 0, "y": 0, "parent": f})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": t, "props": {"fontSize": 20.0, "lineHeight": "150%", "letterSpacing": "10%"}})).unwrap();
let doc = s.doc().unwrap();
let ark = code_for(doc, f, CodeLang::ArkUi);
assert!(ark.contains(".lineHeight(30)"), "{ark}");
assert!(ark.contains(".letterSpacing(2)"), "{ark}");
assert!(!ark.contains('%'), "no percent strings: {ark}");
}
#[test]
fn arkui_places_a_child_that_ignores_its_auto_layout_parent() {
let mut s = Session::new();
+3 -2
View File
@@ -55,12 +55,13 @@ fn text_layer(ctx: &Ctx, n: &Node, t: &figcraft_doc::TextData, pad: &str, out: &
}
match s.line_height {
LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))),
LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))),
// ArkUI takes a number of vp, not a percent string: a percent is of the font size.
LineHeight::Percent(p) => mods.push(format!(".lineHeight({})", num(s.font_size * p / 100.0))),
LineHeight::Auto => {}
}
match s.letter_spacing {
LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))),
LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))),
LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing({})", num(s.font_size * p / 100.0))),
_ => {}
}
match t.align_h {
@@ -38,6 +38,19 @@ fn status(s: &mut Session, ids: &[NodeId], st: &str) {
ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}}));
}
#[test]
fn a_dev_mode_click_on_a_selected_layer_selects_just_it() {
let mut s = Session::new();
let a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})));
let b = idv(&ex(&mut s, "shape.rectangle", json!({"x": 80, "y": 0, "width": 40, "height": 40})));
s.tool.dev_mode = true;
ex(&mut s, "select.set", json!({"ids": [a, b]}));
// Dev Mode starts no move gesture, so the click narrows the selection at the press itself.
s.pointer(&ev(PointerKind::Down, 20.0, 20.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 20.0, 20.0)).unwrap();
assert_eq!(s.selection(), vec![a], "a click on one of several selected layers selects it");
}
#[test]
fn the_dev_mode_canvas_selects_and_pans_but_never_edits() {
let mut s = Session::new();
+15
View File
@@ -62,6 +62,21 @@ fn pressing_a_rotated_frames_label_selects_and_drags_the_frame() {
assert!(s.selection().is_empty());
}
#[test]
fn a_click_on_a_selected_frames_label_selects_only_it() {
let (mut s, f, q) = setup();
let other = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 500, "y": 500, "width": 40, "height": 40}))["id"]).unwrap();
ex(&mut s, "select.set", json!({"ids": [f, other]}));
// A point on the label's left edge, clear of the selection box's resize handles, lies outside
// the selection's bounds (the strip starts left of the frame's left edge there).
let p = q[0] + (q[3] - q[0]) * 0.2 + (q[1] - q[0]) * 0.02;
assert!(s.doc().unwrap().bounds_of(&[f, other]).is_some_and(|b| !b.contains(p)), "the label point is outside the selection bounds");
assert_eq!(label_at(s.doc().unwrap(), s.page().unwrap(), &s.tool.labels, p), Some(f), "the point is on the label");
s.pointer(&ev(PointerKind::Down, p)).unwrap();
s.pointer(&ev(PointerKind::Up, p)).unwrap();
assert_eq!(s.selection(), vec![f], "the label outside the bounds still narrows the selection to its frame");
}
#[test]
fn labels_of_frames_that_cant_be_picked_do_nothing() {
let (mut s, f, q) = setup();
+4
View File
@@ -166,6 +166,10 @@ pub struct Clipboard {
pub paint: Option<(bool, figcraft_color::Paint)>,
/// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint.
pub stroke: Option<figcraft_doc::StrokeProps>,
/// The image data the copied paint names, carried so a paste into another file has it.
pub paint_images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
/// The file the copied paint came from: a pattern's node id only means anything there.
pub paint_source_doc: u64,
/// A copied effect row (`effect.copy`).
pub effect: Option<figcraft_doc::Effect>,
/// The page-space bounds of the copied selection.
+93
View File
@@ -103,6 +103,99 @@ fn pasting_design_layers_keeps_a_copied_row() {
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
}
#[test]
fn a_copied_image_fill_brings_its_image_to_another_file() {
let mut s = Session::new();
let a = rect(&mut s);
ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": "img-key"}}));
let data = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 2, height: 2 };
s.doc_mut().unwrap().images.insert("img-key".into(), data.clone());
ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
// Another file has no image under that key: the copy brings the data with it.
ex(&mut s, "file.new", json!({}));
let b = rect(&mut s);
assert!(!s.doc().unwrap().images.contains_key("img-key"));
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
assert_eq!(s.doc().unwrap().images.get("img-key"), Some(&data));
assert!(matches!(node(&s, b).fills[0].kind, figcraft_color::PaintKind::Image(_)));
}
#[test]
fn a_copied_image_key_taken_in_another_file_gets_the_copied_data() {
let mut s = Session::new();
let a = rect(&mut s);
let copied = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 2, height: 2 };
let taken = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![9, 9, 9]), width: 3, height: 3 };
ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": "img-key"}}));
s.doc_mut().unwrap().images.insert("img-key".into(), copied.clone());
ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
// The other file already uses that key for different data: the pasted paint takes a content
// key for the copied data instead, and the destination's image stays as it is.
ex(&mut s, "file.new", json!({}));
let b = rect(&mut s);
s.doc_mut().unwrap().images.insert("img-key".into(), taken.clone());
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
let doc = s.doc().unwrap();
let fill = &doc.node(b).unwrap().fills[0];
let figcraft_color::PaintKind::Image(img) = &fill.kind else { panic!("the pasted fill is an image") };
assert_ne!(img.image, "img-key");
assert_eq!(doc.images.get(&img.image), Some(&copied));
assert_eq!(doc.images.get("img-key"), Some(&taken), "the destination's image stays");
}
#[test]
fn a_copied_image_whose_hash_key_is_also_taken_gets_a_free_key() {
let mut s = Session::new();
let a = rect(&mut s);
let bytes = vec![1, 2, 3];
let copied = figcraft_doc::ImageData { bytes: std::sync::Arc::new(bytes.clone()), width: 2, height: 2 };
let taken = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![9, 9, 9]), width: 3, height: 3 };
// Images are usually keyed by their content hash: here the copied key is that hash, and the
// other file already uses it for other data, so the replacement key is taken as well.
let hash = figcraft_format::image_hash(&bytes);
ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": hash}}));
s.doc_mut().unwrap().images.insert(hash.clone(), copied.clone());
ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
ex(&mut s, "file.new", json!({}));
let b = rect(&mut s);
s.doc_mut().unwrap().images.insert(hash.clone(), taken.clone());
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
let doc = s.doc().unwrap();
let figcraft_color::PaintKind::Image(img) = &doc.node(b).unwrap().fills[0].kind else { panic!("the pasted fill is an image") };
assert_ne!(img.image, hash, "the taken hash is not reused");
assert_eq!(doc.images.get(&img.image), Some(&copied), "the pasted paint points at the copied data");
assert_eq!(doc.images.get(&hash), Some(&taken), "the destination's image stays");
let key = img.image.clone();
// An image key names an archive member, and only letters and digits are read back: the
// numbered key must bring the copied image through a save and reopen.
assert!(key.bytes().all(|b| b.is_ascii_alphanumeric()), "the key is archive-valid: {key}");
let dir = std::env::temp_dir().join(format!("figcraft-image-key-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("pasted.figcraft").to_string_lossy().to_string();
ex(&mut s, "file.save", json!({"path": path}));
let mut t = Session::empty();
ex(&mut t, "file.open", json!({"path": path}));
let saved = t.doc().unwrap();
let figcraft_color::PaintKind::Image(img) = &saved.node(b).unwrap().fills[0].kind else { panic!("the pasted fill is an image") };
assert_eq!(img.image, key, "the reloaded paint keeps its key");
// A reopened image's size is read from its pixels on demand, so only its bytes are compared.
assert_eq!(saved.images.get(&key).map(|i| i.bytes.as_slice()), Some(copied.bytes.as_slice()), "the copied image survives the reload");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_copied_pattern_does_not_paste_into_another_file() {
let mut s = Session::new();
let source = rect(&mut s);
let patterned = rect(&mut s);
ex(&mut s, "fill.pattern", json!({"ids": [patterned], "source": source}));
ex(&mut s, "fill.copy", json!({"id": patterned}));
// Its node id means nothing in another file, where it may name an unrelated layer.
ex(&mut s, "file.new", json!({}));
let b = rect(&mut s);
assert!(s.execute("fill.paste", &json!({"ids": [b], "index": 0})).is_err(), "a pattern stays in its file");
}
#[test]
fn a_copied_pattern_cannot_paste_into_a_layer_it_draws() {
let mut s = Session::new();
@@ -125,6 +125,8 @@ fn clicking_selects_by_the_documented_rules() {
((90.0, 30.0), NONE, vec![k.a], vec![k.b], "with a layer selected, its siblings select at its level"),
((520.0, 20.0), SHIFT, vec![k.c], vec![k.c, k.r], "Shift-click adds to the selection"),
((30.0, 130.0), SHIFT, vec![k.c, k.r], vec![k.r], "Shift-click on a selected layer removes it"),
((520.0, 20.0), NONE, vec![k.r, k.empty], vec![k.r], "a click on one of several selected layers selects just it"),
((520.0, 20.0), CMD, vec![k.r, k.empty], vec![k.r, k.empty], "silent: Cmd/Ctrl-click on a selected layer keeps the whole selection (pinned)"),
((520.0, 120.0), NONE, vec![], vec![k.r2], "a locked layer can't be selected on the canvas: the click goes to what is under it"),
((520.0, 220.0), NONE, vec![k.r], vec![], "a hidden layer can't be selected on the canvas (empty canvas deselects)"),
((390.0, 290.0), NONE, vec![k.r], vec![], "the background of a top-level frame with layers is like the canvas: a click deselects"),
@@ -300,6 +302,18 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() {
assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None);
}
#[test]
fn dragging_one_of_several_selected_layers_moves_them_all() {
let mut s = Session::new();
let k = scene(&mut s);
let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]}));
drag(&mut s, (520.0, 20.0), (540.0, 20.0), NONE);
assert_eq!(sorted(s.selection()), sorted(vec![k.r, k.empty]), "a drag on one of several selected layers keeps them all selected");
assert_eq!(at(&s, k.r), Point::new(520.0, 0.0), "the dragged layer moves");
assert_eq!(at(&s, k.empty), Point::new(620.0, 0.0), "and so do the others");
}
#[test]
fn shift_drag_moves_the_selection() {
let mut s = Session::new();
@@ -442,6 +456,21 @@ fn a_layer_stacked_below_the_selection_doesnt_take_a_drag_from_inside_it() {
assert_eq!(at(&s, gap), Point::new(home.x + 10.0, home.y + 10.0));
}
#[test]
fn a_click_where_nothing_is_hit_inside_a_multiple_selection_keeps_it() {
let mut s = Session::new();
let a = rect(&mut s, 100.0, 300.0, None);
let b = rect(&mut s, 220.0, 300.0, None);
for i in [a, b] {
ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
}
ex(&mut s, "select.set", json!({"ids": [a, b]}));
// The press is inside `a` but its unfilled inside hits nothing: `a` is only the drag fallback,
// not a clicked layer, so a click keeps the selection.
click(&mut s, 120.0, 320.0, NONE);
assert_eq!(sorted(s.selection()), sorted(vec![a, b]));
}
#[test]
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
let mut s = Session::new();
+1
View File
@@ -1063,6 +1063,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "ডেভের জন্য পূর্ববর্তী প্রস্তুত"
"Progressive" = "প্রোগ্রেসিভ"
"Properties" = "প্রপার্টিজ"
"Properties copied" = "প্রপার্টিজ কপি করা হয়েছে"
"Prototype" = "প্রোটোটাইপ"
"Prototype preview" = "প্রোটোটাইপ প্রিভিউ"
"Prototype settings" = "প্রোটোটাইপ সেটিংস"
+1
View File
@@ -1042,6 +1042,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Předchozí připravené pro vývoj"
"Progressive" = "Postupné"
"Properties" = "Vlastnosti"
"Properties copied" = "Vlastnosti zkopírovány"
"Prototype" = "Prototyp"
"Prototype preview" = "Náhled prototypu"
"Prototype settings" = "Nastavení prototypu"
+1
View File
@@ -1011,6 +1011,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Vorheriges bereit für Entwicklung"
"Progressive" = "Progressiv"
"Properties" = "Eigenschaften"
"Properties copied" = "Eigenschaften kopiert"
"Prototype" = "Prototyp"
"Prototype preview" = "Prototyp-Vorschau"
"Prototype settings" = "Prototyp-Einstellungen"
+1
View File
@@ -1095,6 +1095,7 @@ name = "English"
"Previous ready for dev" = "Previous ready for dev"
"Progressive" = "Progressive"
"Properties" = "Properties"
"Properties copied" = "Properties copied"
"Prototype" = "Prototype"
"Prototype preview" = "Prototype preview"
"Prototype settings" = "Prototype settings"
+1
View File
@@ -1092,6 +1092,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Anterior listo para desarrollo"
"Progressive" = "Progresivo"
"Properties" = "Propiedades"
"Properties copied" = "Propiedades copiadas"
"Prototype" = "Prototipo"
"Prototype preview" = "Vista previa del prototipo"
"Prototype settings" = "Ajustes del prototipo"
+1
View File
@@ -1067,6 +1067,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Edellinen kehitysvalmis"
"Progressive" = "Asteittainen"
"Properties" = "Ominaisuudet"
"Properties copied" = "Ominaisuudet kopioitu"
"Prototype" = "Prototyyppi"
"Prototype preview" = "Prototyypin esikatselu"
"Prototype settings" = "Prototyypin asetukset"
+1
View File
@@ -1066,6 +1066,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Prêt pour le dev précédent"
"Progressive" = "Progressif"
"Properties" = "Propriétés"
"Properties copied" = "Propriétés copiées"
"Prototype" = "Prototype"
"Prototype preview" = "Aperçu du prototype"
"Prototype settings" = "Réglages du prototype"
+1
View File
@@ -1049,6 +1049,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Versi siap dev sebelumnya"
"Progressive" = "Progresif"
"Properties" = "Properti"
"Properties copied" = "Properti disalin"
"Prototype" = "Prototype"
"Prototype preview" = "Pratinjau prototype"
"Prototype settings" = "Pengaturan prototype"
+1
View File
@@ -1054,6 +1054,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "前の開発準備完了"
"Progressive" = "プログレッシブ"
"Properties" = "プロパティ"
"Properties copied" = "プロパティをコピーしました"
"Prototype" = "プロトタイプ"
"Prototype preview" = "プロトタイプのプレビュー"
"Prototype settings" = "プロトタイプの設定"
+1
View File
@@ -1075,6 +1075,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Previous ready for dev" = "წინა მზად დეველოპერისთვის"
"Progressive" = "პროგრესული"
"Properties" = "თვისებები"
"Properties copied" = "თვისებები დაკოპირდა"
"Prototype" = "პროტოტიპი"
"Prototype preview" = "პროტოტიპის წინასწარი ნახვა"
"Prototype settings" = "პროტოტიპის პარამეტრები"
+1
View File
@@ -1005,6 +1005,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "이전 개발 준비 완료"
"Progressive" = "점진적"
"Properties" = "속성"
"Properties copied" = "속성 복사됨"
"Prototype" = "프로토타입"
"Prototype preview" = "프로토타입 미리 보기"
"Prototype settings" = "프로토타입 설정"
+1
View File
@@ -1067,6 +1067,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Vorige klaar voor developers"
"Progressive" = "Progressive"
"Properties" = "Eigenschappen"
"Properties copied" = "Eigenschappen gekopieerd"
"Prototype" = "Prototype"
"Prototype preview" = "Prototypevoorvertoning"
"Prototype settings" = "Prototype-instellingen"
+1
View File
@@ -1069,6 +1069,7 @@ name = "Polski"
"Previous ready for dev" = "Poprzedni gotowy do wdrożenia"
"Progressive" = "Progresywny"
"Properties" = "Właściwości"
"Properties copied" = "Właściwości skopiowane"
"Prototype" = "Prototyp"
"Prototype preview" = "Podgląd prototypu"
"Prototype settings" = "Ustawienia prototypu"
+1
View File
@@ -1004,6 +1004,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Anterior pronto para dev"
"Progressive" = "Progressivo"
"Properties" = "Propriedades"
"Properties copied" = "Propriedades copiadas"
"Prototype" = "Protótipo"
"Prototype preview" = "Visualização do protótipo"
"Prototype settings" = "Configurações do protótipo"
+1
View File
@@ -1069,6 +1069,7 @@ name = "Русский"
"Previous ready for dev" = "Предыдущий готовый к разработке"
"Progressive" = "Прогрессивный"
"Properties" = "Свойства"
"Properties copied" = "Свойства скопированы"
"Prototype" = "Прототип"
"Prototype preview" = "Предпросмотр прототипа"
"Prototype settings" = "Настройки прототипа"
+1
View File
@@ -1095,6 +1095,7 @@ name = "Svenska"
"Previous ready for dev" = "Föregående redo för utveckling"
"Progressive" = "Progressiv"
"Properties" = "Egenskaper"
"Properties copied" = "Egenskaper kopierade"
"Prototype" = "Prototyp"
"Prototype preview" = "Förhandsgranskning av prototyp"
"Prototype settings" = "Prototypinställningar"
+1
View File
@@ -1046,6 +1046,7 @@ name = "Türkçe"
"Previous ready for dev" = "Önceki geliştirmeye hazır"
"Progressive" = "Aşamalı"
"Properties" = "Özellikler"
"Properties copied" = "Özellikler kopyalandı"
"Prototype" = "Prototip"
"Prototype preview" = "Prototip önizlemesi"
"Prototype settings" = "Prototip ayarları"
+1
View File
@@ -1013,6 +1013,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "上一个可供开发"
"Progressive" = "渐进"
"Properties" = "属性"
"Properties copied" = "属性已复制"
"Prototype" = "原型"
"Prototype preview" = "原型预览"
"Prototype settings" = "原型设置"
+90
View File
@@ -0,0 +1,90 @@
//! Hovering a blend mode in the layer's or a paint's menu shows it on the canvas as a live
//! preview: no edit, no undo step, and the layer is back as it was when the pointer leaves.
use egui::{Event, Pos2, RawInput, Rect, pos2, vec2};
use figcraft_color::BlendMode;
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn scene() -> (Ui, NodeId) {
let mut s = Session::new();
let a = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20})).unwrap());
s.execute("fill.set", &json!({"ids": [a], "index": 0, "paint": {"type": "solid", "color": "#ff0000"}})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
(Ui::new(s), a)
}
#[test]
fn every_row_of_the_blend_menu_previews_its_own_mode_under_the_pointer() {
let ctx = egui::Context::default();
let mut seen: Vec<BlendMode> = Vec::new();
let mut time = 0.0;
// Down the menu, a row at a time: each row under the pointer reports its mode.
for y in (0..1000).step_by(2) {
time += 1.0 / 60.0;
let input = RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 1000.0))),
time: Some(time),
events: vec![Event::PointerMoved(pos2(40.0, y as f32))],
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
let _ = crate::right::blend_menu(ui, true, |b| seen.push(b));
});
out.textures_delta.clear();
}
seen.dedup();
assert_eq!(seen, BlendMode::ALL.to_vec(), "rows preview their modes top to bottom");
}
#[test]
fn a_hovered_layer_blend_shows_on_the_canvas_and_leaves_no_trace() {
let (mut u, a) = scene();
let ctx = u.ctx.clone();
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal);
let steps = u.app.session.undo_labels(100).len();
crate::right::preview_blend(&mut u.app, &ctx, &[a], BlendMode::Multiply);
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Multiply, "the canvas shows the hovered mode");
assert!(figcraft_engine::textpreview::active(&u.app.session), "as a preview");
// Nothing asks for it on the next frame: the layer is back as it was.
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal, "the preview ended");
assert!(!figcraft_engine::textpreview::active(&u.app.session));
assert_eq!(u.app.session.undo_labels(100).len(), steps, "a preview is no undo step");
}
#[test]
fn picking_after_a_preview_is_one_step_back_to_the_original() {
let (mut u, a) = scene();
let steps = u.app.session.undo_labels(100).len();
crate::right::preview_blend(&mut u.app, &u.ctx.clone(), &[a], BlendMode::Multiply);
// The click lands while the hovered mode shows: the pick applies to the document, not the preview.
u.app.set_props(&[a], json!({"blend": BlendMode::Screen.label()}));
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Screen);
assert!(!figcraft_engine::textpreview::active(&u.app.session), "the pick ended the preview");
assert_eq!(u.app.session.undo_labels(100).len(), steps + 1, "one undo step");
u.app.session.undo().unwrap();
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal, "undo goes back to the original, not the hovered mode");
}
#[test]
fn a_hovered_paint_blend_shows_on_its_layer_and_leaves_no_trace() {
let (mut u, a) = scene();
let target = crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
let paint = u.app.session.doc().unwrap().node(a).unwrap().fills[0].clone();
let steps = u.app.session.undo_labels(100).len();
crate::colorpicker::preview_blend(&mut u.app, &u.ctx.clone(), &target, &paint, BlendMode::Screen);
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().fills[0].blend, BlendMode::Screen, "the fill shows the hovered mode");
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().fills[0].blend, BlendMode::Normal, "the preview ended");
assert_eq!(u.app.session.undo_labels(100).len(), steps, "a preview is no undo step");
}
+18 -2
View File
@@ -183,6 +183,21 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
let primary_pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
let primary_down = ctx.input(|i| i.pointer.primary_down());
let primary_released = ctx.input(|i| i.pointer.button_released(egui::PointerButton::Primary));
// The press and the release carry their own positions: both in one frame (a quick click or a
// slow frame) would otherwise send the press from the hover position the pointer ended at.
let (press_pos, release_pos) = ctx.input(|i| {
let (mut press, mut release) = (None, None);
for e in &i.events {
if let egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } = e {
if *pressed {
press = Some(*pos);
} else {
release = Some(*pos);
}
}
}
(press, release)
});
let kind = if primary_pressed && resp.hovered() {
app.canvas.dragging = true;
app.ui.context_menu = None;
@@ -221,13 +236,14 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
app.canvas.pressure = force;
}
if let Some(k) = kind {
send(app, k, pos, &mods);
let at = if matches!(k, PointerKind::Down) { press_pos.unwrap_or(pos) } else { pos };
send(app, k, at, &mods);
// The pointer also let go within this frame (a quick click, or a slow frame): the tools get
// the release too, so the press's gesture ends — a quick click with a shape tool still
// draws and returns to Move, and a double-click right after drawing finds the Move tool.
if matches!(k, PointerKind::Down) && primary_released {
app.canvas.dragging = false;
send(app, PointerKind::Up, pos, &mods);
send(app, PointerKind::Up, release_pos.unwrap_or(pos), &mods);
}
}
true
+17 -1
View File
@@ -88,6 +88,9 @@ struct Drag {
uid: u64,
page: NodeId,
moved: bool,
/// The modifiers at the press: a release is a click only if it was a plain press, however the
/// modifiers stand when the button comes up.
plain: bool,
}
fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec<NodeId>, p: Pos2, action: Action) {
@@ -95,10 +98,11 @@ fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec<NodeId>, p: Pos2,
Action::Spacing { .. } => tr("Spacing"),
Action::Reorder { .. } => tr("Reorder selection"),
};
let plain = ctx.input(|i| !(i.modifiers.shift || i.modifiers.alt || i.modifiers.command || i.modifiers.ctrl));
if app.session.begin_interaction(label).is_ok()
&& let Ok(ds) = app.session.ds()
{
let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false };
let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false, plain };
app.ui.layers_focus = false;
app.ui.pages_focus = None;
app.ui.key_box = None;
@@ -280,8 +284,20 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered:
Action::Reorder { .. } => CursorIcon::Grabbing,
});
if !down {
// A plain press on a reorder ring that didn't become a drag is a click on that layer:
// the selection narrows to it, as a click on the canvas would. The press's modifiers
// decide (letting Shift go before the release doesn't make it a plain click), and a
// release away from where it pressed (outside the canvas, say) ends a drag, not a click.
let released = pos.is_some_and(|p| app.canvas.rect.contains(p) && p.distance(drag.start) < 3.0);
let clicked = match &drag.action {
Action::Reorder { source, .. } if drag.plain && !drag.moved && released => Some(*source),
_ => None,
};
let _ = app.session.commit_interaction();
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
if let Some(id) = clicked {
app.session.set_selection(vec![id]);
}
} else {
ctx.data_mut(|d| d.insert_temp(drag_id(), drag));
}
+24 -1
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use egui::{Pos2, Shape};
use egui::{Event, Modifiers, PointerButton, Pos2, Shape};
use figcraft_engine::Session;
use serde_json::json;
@@ -25,6 +25,29 @@ fn dashes_along(shapes: &[Shape], a: Pos2, b: Pos2) -> usize {
shapes.iter().filter(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if on(*p) && on(*q))).count()
}
#[test]
fn a_press_and_release_in_one_frame_draw_from_the_press() {
// A quick drag (or a slow frame) puts both in one frame, where the hover position is the end:
// the press must still use its own position, or the shape starts where the pointer let go.
let mut u = Ui::new(Session::new());
u.app.run("tool.set", json!({"tool": "rectangle"}));
u.frame(vec![]);
let press = u.app.to_screen(100.0, 100.0);
let release = u.app.to_screen(260.0, 220.0);
u.frame(vec![
Event::PointerMoved(release),
Event::PointerButton { pos: press, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerButton { pos: release, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
]);
for _ in 0..3 {
u.frame(vec![]);
}
let doc = u.app.session.doc().unwrap();
let drawn = u.app.session.selection().first().copied().expect("the press drew a shape");
let b = doc.world_bounds(drawn).expect("the shape has bounds");
assert!((b.x0 - 100.0).abs() < 1.0 && (b.y0 - 100.0).abs() < 1.0, "drawn from the press, not the release: {b:?}");
}
#[test]
fn frame_outlines_and_slices_draw_when_shown() {
let mut s = Session::new();
+4
View File
@@ -38,6 +38,8 @@ mod a11y_tests;
#[cfg(test)]
mod async_dialog_tests;
#[cfg(test)]
mod blend_preview_tests;
#[cfg(test)]
mod branch_ui_tests;
#[cfg(test)]
mod canvas_double_click_tests;
@@ -191,6 +193,8 @@ mod stroke_row_tests;
#[cfg(test)]
mod style_folder_rename_tests;
#[cfg(test)]
mod style_props_tests;
#[cfg(test)]
mod text_enter_tests;
#[cfg(test)]
mod titlebar_tests;
+5
View File
@@ -188,6 +188,11 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"collab.join" => crate::live::open_join(app, ""),
// So is the server address for publishing (it carries the server key).
"collab.publish" => crate::live::open_publish(app),
"edit.copyProperties" => {
if app.run(id, json!({})).is_some() {
app.toast(tr("Properties copied"));
}
}
"edit.copy" | "edit.cut" => {
let ids = app.session.selection();
// For other apps: text layers' text, other layers as SVG; taken before a cut removes them.
+2
View File
@@ -31,6 +31,8 @@ use inspect::inspect;
pub(crate) use inspect::{inspect_section, prop_row};
#[cfg(test)]
pub(crate) use menu::menu_row_width;
#[cfg(test)]
pub(crate) use menu::preview_blend;
pub use menu::{blend_menu, context_popup, menu_check, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row};
pub(crate) use menu::{block, menu_style, row, row_rect};
use paints::{appearance, paints, variable_modes};
+19 -9
View File
@@ -1,4 +1,4 @@
use super::{BlendMode, Color32, CornerRadius, FIELD_H, Id, PAD, Rect, Sense, Tokens, Ui, a11y, pos2, regular, tr, vec2};
use super::{BlendMode, Color32, CornerRadius, FIELD_H, FigApp, Id, NodeId, PAD, Rect, Sense, Tokens, Ui, a11y, json, pos2, regular, tr, vec2};
/// The smallest width a popup menu is measured in (see [`menu_popup`]).
const MENU_MIN_W: f32 = 48.0;
@@ -14,16 +14,16 @@ pub(crate) fn menu_style(s: &mut egui::Style) {
s.wrap_mode = Some(egui::TextWrapMode::Extend);
}
/// The menu a dropdown or button opens: as wide as its widest row and at least as wide as
/// `anchor`. egui measures a popup in a first, invisible pass as wide as the width it is given
/// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the
/// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a
/// menu at least `w` wide.
/// The blend modes as menu items in groups (pass-through only for layers); the one picked.
pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option<BlendMode> {
/// The blend modes as menu items in groups (pass-through only for layers); the one picked. Each
/// row `preview`s its mode on the canvas while hovered.
pub fn blend_menu(ui: &mut Ui, with_pass_through: bool, mut preview: impl FnMut(BlendMode)) -> Option<BlendMode> {
let mut picked = None;
for b in BlendMode::ALL.into_iter().filter(|b| with_pass_through || *b != BlendMode::PassThrough) {
if menu_item(ui, tr(b.label()), "") {
let resp = menu_row(ui, tr(b.label()), "", 0.0, |_, _, _| {});
if resp.hovered() {
preview(b);
}
if resp.clicked() {
picked = Some(b);
ui.close();
}
@@ -34,6 +34,16 @@ pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option<BlendMode> {
picked
}
/// Show `blend` on `ids` on the canvas while its row in a blend mode menu is hovered.
pub(crate) fn preview_blend(app: &mut FigApp, ctx: &egui::Context, ids: &[NodeId], blend: BlendMode) {
app.preview_text(ctx, "node.set", json!({"ids": ids, "props": {"blend": blend.label()}}));
}
/// The menu a dropdown or button opens: as wide as its widest row and at least as wide as
/// `anchor`. egui measures a popup in a first, invisible pass as wide as the width it is given
/// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the
/// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a
/// menu at least `w` wide.
pub fn menu_popup(anchor: &egui::Response) -> egui::Popup<'static> {
egui::Popup::menu(anchor).width(anchor.rect.width().max(MENU_MIN_W)).style(menu_style)
}
+3 -2
View File
@@ -1,4 +1,4 @@
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_rect};
use super::stroke::stroke_settings;
use super::{
Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, PAD, Paint,
@@ -132,7 +132,8 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
let label = blend.map(|b| tr(b.label())).unwrap_or(tr("Mixed"));
let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend));
menu_popup(&resp).width(200.0).show(|ui| {
if let Some(b) = blend_menu(ui, true) {
let ctx = ui.ctx().clone();
if let Some(b) = blend_menu(ui, true, |b| preview_blend(app, &ctx, &ids, b)) {
app.set_props(&ids, json!({"blend": b.label()}));
}
});
+45 -1
View File
@@ -228,6 +228,45 @@ fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() {
assert_eq!(xy(&u, ids[0]), (180., 200.));
}
#[test]
fn a_click_on_one_of_several_selected_layers_selects_it() {
let (mut u, ids) = scene(false);
// The reorder ring sits at the layer's centre and takes the press; a click (no drag) on it is
// a click on the layer, which then has the selection to itself.
let p = u.app.to_screen(125., 130.);
u.frame(vec![Event::PointerMoved(p)]);
u.button(p, true);
u.button(p, false);
u.frame(vec![]);
assert_eq!(u.app.session.selection(), vec![ids[0]], "the click selects the layer whose ring it hit");
}
#[test]
fn the_press_modifiers_decide_whether_a_ring_release_is_a_click() {
let (mut u, ids) = scene(false);
let p = u.app.to_screen(125., 130.);
let shift = Modifiers::SHIFT;
// Shift is down at the press and let go before the release: the ring keeps its Shift meaning.
u.frame_with(vec![Event::PointerMoved(p)], shift);
u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: shift }], shift);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]);
u.frame(vec![]);
assert_eq!(u.app.session.selection(), ids, "letting Shift go before the release doesn't make it a plain click");
}
#[test]
fn releasing_a_ring_outside_the_canvas_ends_a_drag_even_without_motion() {
let (mut u, ids) = scene(false);
let p = u.app.to_screen(125., 130.);
u.frame(vec![Event::PointerMoved(p)]);
u.button(p, true);
// The pointer leaves before any in-canvas motion is seen, so `moved` stays false.
u.frame(vec![Event::PointerGone]);
u.button(Pos2::new(-1., -1.), false);
u.frame(vec![]);
assert_eq!(u.app.session.selection(), ids, "a release away from the press ends a drag, not a click");
}
#[test]
fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() {
for action in [0, 1, 2] {
@@ -252,7 +291,12 @@ fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() {
u.frame(vec![]);
assert_eq!(u.app.session.doc().unwrap(), &before);
assert_eq!(u.app.session.undo_depth().0, history);
assert_eq!(u.app.session.selection(), ids);
if action == 0 {
// The press on a ring that doesn't drag is a click on that layer.
assert_eq!(u.app.session.selection(), vec![ids[0]], "a click on a ring selects its layer");
} else {
assert_eq!(u.app.session.selection(), ids);
}
assert!(u.app.session.ds().unwrap().interaction.is_none());
}
}
+6 -1
View File
@@ -179,7 +179,12 @@ pub(crate) fn handle_input(
let now = ctx.input(|i| i.time);
for ev in &events {
match ev {
Event::Copy => crate::menus::invoke(app, "edit.copy"),
// egui-winit sends Cmd/Ctrl+C as a copy event whatever Alt does, so Copy properties
// (Alt+Cmd/Ctrl+C) arrives here and never as a key.
Event::Copy => {
let props = ctx.input(|i| i.modifiers.alt);
crate::menus::invoke(app, if props { "edit.copyProperties" } else { "edit.copy" });
}
Event::Cut => cut(app),
Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None),
Event::Key { key, physical_key, pressed: true, modifiers, repeat }
+119
View File
@@ -0,0 +1,119 @@
//! Copying a layer's style with Cmd+Alt+C / Ctrl+Alt+C and pasting it onto other layers with
//! Cmd+Alt+V / Ctrl+Alt+V: fills, strokes, corners, effects, opacity and blend mode.
use egui::{Event, Key, Modifiers};
use figcraft_doc::{NodeId, NodeKind};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// The copy and paste keys of the platform the tests run on.
fn chord() -> Modifiers {
if cfg!(target_os = "macos") {
Modifiers { alt: true, command: true, ..Default::default() }
} else {
Modifiers { alt: true, ctrl: true, command: true, ..Default::default() }
}
}
/// Cmd/Ctrl+Alt+V as the desktop shell delivers it: the press is swallowed, the release arrives.
fn release_v(u: &mut Ui) {
let m = chord();
u.frame_with(vec![Event::Key { key: Key::V, physical_key: Some(Key::V), pressed: false, repeat: false, modifiers: m }], m);
}
/// Two rectangles, the first with a full style and selected.
fn scene() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let styled = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20})).unwrap());
let plain = id(s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 0, "width": 40, "height": 20})).unwrap());
s.execute(
"node.set",
&json!({"id": styled, "props": {
"fills": [{"type": "solid", "color": "#ff0000"}],
"strokes": [{"type": "solid", "color": "#0000ff"}],
"strokeWeight": 3,
"strokeAlign": "outside",
"strokeSides": [1, 2, 3, 4],
"startCap": "round",
"endCap": "square",
"strokeJoin": "bevel",
"miterLimit": 7,
"dashes": [4, 2],
"widthProfile": [[0, 0.5], [1, 1]],
"brush": "taper",
"cornerRadius": 6,
"cornerSmoothing": 0.6,
"effects": [{"type": "dropShadow", "offset": [2, 4], "radius": 5, "spread": 1}],
"opacity": 0.5,
"blend": "multiply",
}}),
)
.unwrap();
s.execute("select.set", &json!({"ids": [styled]})).unwrap();
(Ui::new(s), styled, plain)
}
#[test]
fn the_copy_chord_arrives_as_a_copy_event_and_takes_properties_not_layers() {
let (mut u, _styled, _plain) = scene();
// egui-winit sends Cmd/Ctrl+C as a copy event whatever Alt does: the key never reaches the
// keymap, and Alt is what tells Copy properties from Copy.
u.frame_with(vec![Event::Copy], chord());
assert!(u.app.session.clipboard.props.is_some(), "Copy properties ran from the copy event");
assert!(u.app.session.clipboard.nodes.is_empty(), "the layers were not copied");
assert!(u.app.ui.toast.is_some(), "the copy shows a confirmation");
}
#[test]
fn the_style_keys_copy_and_paste_every_style_property() {
let (mut u, styled, plain) = scene();
u.frame_with(vec![Event::Copy], chord());
u.app.session.execute("select.set", &json!({"ids": [plain]})).unwrap();
release_v(&mut u);
let doc = u.app.session.doc().unwrap();
let (a, b) = (doc.node(styled).unwrap(), doc.node(plain).unwrap());
assert_eq!(b.fills, a.fills, "fills");
assert_eq!(b.stroke, a.stroke, "strokes: paints, weight, align, sides, caps, join, dashes, width profile, brush");
assert_eq!(b.corners, a.corners, "corner radii and smoothing");
assert_eq!(b.effects, a.effects, "effects");
assert_eq!(b.opacity, a.opacity, "layer opacity");
assert_eq!(b.blend, a.blend, "blend mode");
}
#[test]
fn a_vectors_anchor_radii_are_not_part_of_the_copied_style() {
// Radii set on anchors in edit mode live in the network's vertices, not in the layer's corners:
// copying such a vector's style and pasting it back must leave every anchor's radius as it was.
let mut s = Session::new();
let v = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 40})).unwrap());
s.execute("vector.edit.begin", &json!({"id": v})).unwrap();
s.execute("vector.edit.cornerRadius", &json!({"radius": 6, "vertices": [0, 1]})).unwrap();
s.execute("vector.edit.end", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [v]})).unwrap();
let radii = |s: &Session| match &s.doc().unwrap().node(v).unwrap().kind {
NodeKind::Vector { network } => network.vertices.iter().map(|p| p.radius).collect::<Vec<_>>(),
_ => panic!("the layer was converted to a vector"),
};
let before = radii(&s);
assert_eq!(&before[..2], &[6.0, 6.0], "two anchors are rounded");
let mut u = Ui::new(s);
u.frame_with(vec![Event::Copy], chord());
release_v(&mut u);
assert_eq!(radii(&u.app.session), before, "the paste left every anchor's radius alone");
}
#[test]
fn pasting_a_style_leaves_the_geometry_alone() {
let (mut u, _styled, plain) = scene();
u.frame_with(vec![Event::Copy], chord());
u.app.session.execute("select.set", &json!({"ids": [plain]})).unwrap();
release_v(&mut u);
let b = u.app.session.doc().unwrap().world_bounds(plain).unwrap();
assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 0.0, 40.0, 20.0), "the layer keeps its place and size");
}
+48 -35
View File
@@ -3,7 +3,7 @@
use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::{
Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint,
BlendMode, Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint,
PatternTile,
};
use figcraft_doc::{NodeId, NodeKind};
@@ -118,6 +118,40 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
}
}
/// The command and its params that write `paint` to `target`, when the target can take it.
fn paint_command(app: &FigApp, target: &PickerTarget, paint: &Paint) -> Option<(&'static str, serde_json::Value)> {
Some(match target {
PickerTarget::PluginField { .. } => return None,
PickerTarget::SelectionColor { targets, .. } => ("selection.setPaint", json!({"targets": targets, "paint": paint})),
PickerTarget::PageBackground => {
let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE);
("page.setBackground", json!({"color": c.to_css_hex()}))
}
PickerTarget::Paint { fills, index, ids } => {
(if *fills { "fill.set" } else { "stroke.set" }, json!({"ids": ids, "index": index, "paint": paint}))
}
PickerTarget::Effect { ids, index, secondary } => {
let mut e = ids.first().and_then(|i| app.session.doc().ok()?.node(*i)?.effects.get(*index).cloned())?;
if let Some(c) = e.color_mut(*secondary) {
*c = paint.solid_color().unwrap_or(Color::BLACK);
}
("effect.set", json!({"ids": ids, "index": index, "effect": e}))
}
PickerTarget::TextRange { id, start, end } => ("text.style", json!({"id": id, "start": start, "end": end, "textFills": [paint]})),
PickerTarget::Variable { id, mode } => {
let c = paint.solid_color().unwrap_or(Color::BLACK);
("variable.set", json!({"variable": id, "mode": mode, "value": c.to_css_hex()}))
}
PickerTarget::Style { id, index } => {
let mut paints = app.session.doc().ok().and_then(|d| style_paints(d, id))?;
if let Some(slot) = paints.get_mut(*index) {
*slot = paint.clone();
}
("style.update", json!({"style": id, "value": paints}))
}
})
}
/// Write `paint` to the target. `live` edits are part of an ongoing drag.
fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
if let PickerTarget::PluginField { plugin, control, .. } = target {
@@ -132,39 +166,8 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
if live {
let _ = app.session.begin_interaction("Color");
}
let r = match target {
PickerTarget::SelectionColor { targets, .. } => app.session.execute("selection.setPaint", &json!({"targets": targets, "paint": paint})),
PickerTarget::PageBackground => {
let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE);
app.session.execute("page.setBackground", &json!({"color": c.to_css_hex()}))
}
PickerTarget::Paint { fills, index, ids } => {
app.session.execute(if *fills { "fill.set" } else { "stroke.set" }, &json!({"ids": ids, "index": index, "paint": paint}))
}
PickerTarget::Effect { ids, index, secondary } => {
let Some(mut e) = ids.first().and_then(|i| app.session.doc().ok()?.node(*i)?.effects.get(*index).cloned()) else { return };
if let Some(c) = e.color_mut(*secondary) {
*c = paint.solid_color().unwrap_or(Color::BLACK);
}
app.session.execute("effect.set", &json!({"ids": ids, "index": index, "effect": e}))
}
PickerTarget::TextRange { id, start, end } => {
app.session.execute("text.style", &json!({"id": id, "start": start, "end": end, "textFills": [paint]}))
}
PickerTarget::Variable { id, mode } => {
let c = paint.solid_color().unwrap_or(Color::BLACK);
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()}))
}
PickerTarget::PluginField { .. } => return,
PickerTarget::Style { id, index } => {
let Some(mut paints) = app.session.doc().ok().and_then(|d| style_paints(d, id)) else { return };
if let Some(slot) = paints.get_mut(*index) {
*slot = paint.clone();
}
app.session.execute("style.update", &json!({"style": id, "value": paints}))
}
};
if let Err(e) = r {
let Some((cmd, params)) = paint_command(app, target, paint) else { return };
if let Err(e) = app.session.execute(cmd, &params) {
app.toast(e.to_string());
}
// Drag previews (`live`) are not choices: a drag records only the colour it ends on. Not "an
@@ -174,6 +177,15 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
}
}
/// Show the target's paint with `blend` on the canvas while its blend mode row is hovered.
pub(crate) fn preview_blend(app: &mut FigApp, ctx: &egui::Context, target: &PickerTarget, paint: &Paint, blend: BlendMode) {
let mut p = paint.clone();
p.blend = blend;
if let Some((cmd, params)) = paint_command(app, target, &p) {
app.preview_text(ctx, cmd, params);
}
}
fn remember_recent(app: &mut FigApp, c: Color) {
// By 8-bit value: a document stores colours rounded to 8 bits, so the colour picked and the one read
// back differ by a fraction and would show as two identical swatches.
@@ -610,7 +622,8 @@ fn blend_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &mut P
ui.add_space(16.0);
let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
crate::right::menu_popup(&resp).width(200.0).show(|ui| {
if let Some(b) = crate::right::blend_menu(ui, false) {
let ctx = ui.ctx().clone();
if let Some(b) = crate::right::blend_menu(ui, false, |b| preview_blend(app, &ctx, target, paint, b)) {
paint.blend = b;
write(app, target, paint, false);
}
+2 -1
View File
@@ -98,7 +98,8 @@ Copy-Item -LiteralPath (Join-Path $Root 'NOTICE') -Destination $Stage
$Exes = $Bins | ForEach-Object { "$_.exe" }
foreach ($exe in $Exes) { Copy-Item (Join-Path $Bin $exe) $Stage }
& (Join-Path $PSScriptRoot 'sign.ps1') @($Exes | ForEach-Object { Join-Path $Stage $_ })
$SignFiles = @($Exes | ForEach-Object { Join-Path $Stage $_ })
& (Join-Path $PSScriptRoot 'sign.ps1') @SignFiles
# ---- font notices for the MSI ------------------------------------------------------------------
# The portable ZIP already carries these notices; stage the complete files before MSI creation too.