mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
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:
@@ -8,3 +8,5 @@ plans/
|
||||
/log/
|
||||
.DS_Store
|
||||
*.profraw
|
||||
.cache/
|
||||
.vscode/
|
||||
|
||||
@@ -89,3 +89,6 @@ sync_github_name = true
|
||||
|
||||
[people.mateusvillain]
|
||||
sync_github_name = true
|
||||
|
||||
[people.culiulemon]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1063,6 +1063,7 @@ status = "draft — needs native review"
|
||||
"Previous ready for dev" = "ডেভের জন্য পূর্ববর্তী প্রস্তুত"
|
||||
"Progressive" = "প্রোগ্রেসিভ"
|
||||
"Properties" = "প্রপার্টিজ"
|
||||
"Properties copied" = "প্রপার্টিজ কপি করা হয়েছে"
|
||||
"Prototype" = "প্রোটোটাইপ"
|
||||
"Prototype preview" = "প্রোটোটাইপ প্রিভিউ"
|
||||
"Prototype settings" = "প্রোটোটাইপ সেটিংস"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1054,6 +1054,7 @@ status = "draft — needs native review"
|
||||
"Previous ready for dev" = "前の開発準備完了"
|
||||
"Progressive" = "プログレッシブ"
|
||||
"Properties" = "プロパティ"
|
||||
"Properties copied" = "プロパティをコピーしました"
|
||||
"Prototype" = "プロトタイプ"
|
||||
"Prototype preview" = "プロトタイプのプレビュー"
|
||||
"Prototype settings" = "プロトタイプの設定"
|
||||
|
||||
@@ -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" = "პროტოტიპის პარამეტრები"
|
||||
|
||||
@@ -1005,6 +1005,7 @@ status = "draft — needs native review"
|
||||
"Previous ready for dev" = "이전 개발 준비 완료"
|
||||
"Progressive" = "점진적"
|
||||
"Properties" = "속성"
|
||||
"Properties copied" = "속성 복사됨"
|
||||
"Prototype" = "프로토타입"
|
||||
"Prototype preview" = "프로토타입 미리 보기"
|
||||
"Prototype settings" = "프로토타입 설정"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1069,6 +1069,7 @@ name = "Русский"
|
||||
"Previous ready for dev" = "Предыдущий готовый к разработке"
|
||||
"Progressive" = "Прогрессивный"
|
||||
"Properties" = "Свойства"
|
||||
"Properties copied" = "Свойства скопированы"
|
||||
"Prototype" = "Прототип"
|
||||
"Prototype preview" = "Предпросмотр прототипа"
|
||||
"Prototype settings" = "Настройки прототипа"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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ı"
|
||||
|
||||
@@ -1013,6 +1013,7 @@ status = "draft — needs native review"
|
||||
"Previous ready for dev" = "上一个可供开发"
|
||||
"Progressive" = "渐进"
|
||||
"Properties" = "属性"
|
||||
"Properties copied" = "属性已复制"
|
||||
"Prototype" = "原型"
|
||||
"Prototype preview" = "原型预览"
|
||||
"Prototype settings" = "原型设置"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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, ¶ms) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user