mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
M3.52: Eyedropper Options with Picks Up / Applies trees, Alt and Shift+Alt modes, image sampling
Double-clicking the Eyedropper opens Eyedropper Options (dialog eyedropperOptions): raster sample size (point, 3x3, 5x5) and Picks Up / Applies trees (transparency; focal fill colour, transparency, overprint; focal stroke colour, transparency, overprint, weight, cap, join, miter, dash; character and paragraph attributes). The options live in the preferences (Prefs::eyedropper, eyedropper.setOptions, prefs key eyedropper) and replace the earlier appearance/transparency switches. appearance.copyFrom takes pickUp/apply overrides, reverse (Alt-click: the selection's attributes onto the clicked object) and append (Shift+Alt-click: add the source's fills and strokes). Type copies font, size and paragraph attributes to type. Clicking an image samples its pixels; sampled colours keep their colour model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2e6662c93b
commit
303689c8f4
@@ -19,12 +19,14 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::opacitymask::percent;
|
||||
use super::paint::paint_from;
|
||||
use super::*;
|
||||
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -115,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, appearance?: bool, transparency?: bool (default: eyedropper.setOptions)} copy fill, stroke, weight and effects (appearance) and opacity and blend (transparency) from `source` to ids (default: selection), the appearance also to the paint defaults; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art)",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: the selection), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -630,68 +632,192 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
|
||||
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
|
||||
// The appearance and the box its placed gradients are relative to (type: its first run's
|
||||
// paints, in text space).
|
||||
let (appearance, src_box) = match &src.kind {
|
||||
NodeKind::Text(t) if !t.runs.is_empty() => {
|
||||
let r = &t.runs[0];
|
||||
(r.style.basic_appearance(), Some(t.local_bounds()))
|
||||
}
|
||||
_ => (src.appearance.clone(), src.geometric_bounds()),
|
||||
};
|
||||
// `appearance` placed on an object whose box (in its paint space) is `to`.
|
||||
let placed = |to: Option<vectorcraft_geom::Rect>| {
|
||||
let mut ap = appearance.clone();
|
||||
if let (Some(f), Some(t)) = (src_box, to) {
|
||||
ap.rebase_gradients(f, t);
|
||||
}
|
||||
ap
|
||||
};
|
||||
let opts = s.eyedropper.with(p);
|
||||
if opts.appearance {
|
||||
s.paint.fill = super::gradient::unplaced(&appearance.fill_paint());
|
||||
s.paint.stroke = super::gradient::unplaced(&appearance.stroke_paint());
|
||||
s.remember_paint(&s.paint.fill.clone());
|
||||
if appearance.stroke().is_some() {
|
||||
s.paint.stroke_width = appearance.stroke_width();
|
||||
}
|
||||
}
|
||||
let ids = match ids_param(p, "ids") {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
let selected = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
let mut targets = leaf_targets(s, &ids)?;
|
||||
// Reversed, the first selected object's attributes go onto the clicked object.
|
||||
let (src_id, mut targets) = if reverse {
|
||||
let first = leaf_targets(s, &selected)?.into_iter().next().ok_or_else(|| bad(C, "select the object to copy from"))?;
|
||||
(first, leaf_targets(s, &[clicked])?)
|
||||
} else {
|
||||
(clicked, leaf_targets(s, &selected)?)
|
||||
};
|
||||
targets.retain(|id| *id != src_id);
|
||||
if targets.is_empty() || !(opts.appearance || opts.transparency) {
|
||||
let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?);
|
||||
if !reverse {
|
||||
picked.to_defaults(s, &attrs.appearance);
|
||||
}
|
||||
if targets.is_empty() || attrs.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
let (opacity, blend) = (src.opacity, src.blend);
|
||||
s.edit("Eyedropper", |d, _| {
|
||||
for id in &targets {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if opts.transparency {
|
||||
n.opacity = opacity;
|
||||
n.blend = blend;
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
picked.apply(n, &attrs, append);
|
||||
}
|
||||
if !opts.appearance {
|
||||
continue;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let ap = placed(Some(t.local_bounds()));
|
||||
// Characters without a painted stroke get no weight.
|
||||
let stroke =
|
||||
ap.stroke().map_or_else(|| StrokeLayer::new(Paint::None, 0.0), |s| StrokeLayer { width: ap.stroke_width(), ..s.clone() });
|
||||
for r in &mut t.runs {
|
||||
r.style.fill = ap.fill_paint();
|
||||
r.style.set_stroke_layer(&stroke);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
n.appearance = placed(n.geometric_bounds());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// What the Eyedropper picks up from an object.
|
||||
struct Picked {
|
||||
/// The appearance stack (type: its first run's fill and stroke as a basic appearance).
|
||||
appearance: Appearance,
|
||||
/// The box (in the source's paint space) its placed gradients are relative to.
|
||||
bounds: Option<Rect>,
|
||||
opacity: f32,
|
||||
blend: BlendMode,
|
||||
/// Type's first character style and its paragraph attributes.
|
||||
text: Option<(CharStyle, ParaStyle)>,
|
||||
}
|
||||
|
||||
impl Picked {
|
||||
fn of(n: &Node) -> Self {
|
||||
let (appearance, bounds, text) = match &n.kind {
|
||||
NodeKind::Text(t) if !t.runs.is_empty() => {
|
||||
let st = &t.runs[0].style;
|
||||
(st.basic_appearance(), Some(t.local_bounds()), Some((st.clone(), t.para.clone())))
|
||||
}
|
||||
_ => (n.appearance.clone(), n.geometric_bounds(), None),
|
||||
};
|
||||
Self { appearance, bounds, opacity: n.opacity, blend: n.blend, text }
|
||||
}
|
||||
|
||||
/// The appearance placed on an object whose box (in its paint space) is `to`: placed gradients
|
||||
/// land at the same place relative to it.
|
||||
fn placed(&self, to: Option<Rect>) -> Appearance {
|
||||
let mut ap = self.appearance.clone();
|
||||
if let (Some(f), Some(t)) = (self.bounds, to) {
|
||||
ap.rebase_gradients(f, t);
|
||||
}
|
||||
ap
|
||||
}
|
||||
|
||||
/// The paint defaults for new art take the picked-up fill, stroke and weight.
|
||||
fn to_defaults(&self, s: &mut Session, a: &AppearanceAttrs) {
|
||||
if a.fill.color {
|
||||
s.paint.fill = super::gradient::unplaced(&self.appearance.fill_paint());
|
||||
s.remember_paint(&s.paint.fill.clone());
|
||||
}
|
||||
if a.stroke.color {
|
||||
s.paint.stroke = super::gradient::unplaced(&self.appearance.stroke_paint());
|
||||
}
|
||||
if a.stroke.weight && self.appearance.stroke().is_some() {
|
||||
s.paint.stroke_width = self.appearance.stroke_width();
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its
|
||||
/// stack instead. Type takes the paints on its characters, and character and paragraph
|
||||
/// attributes from type.
|
||||
fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool) {
|
||||
let ap = &a.appearance;
|
||||
if ap.transparency {
|
||||
n.opacity = self.opacity;
|
||||
n.blend = self.blend;
|
||||
}
|
||||
if append {
|
||||
let placed = self.placed(n.geometric_bounds());
|
||||
n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color }));
|
||||
n.appearance.effects.extend(placed.effects);
|
||||
return;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let placed = self.placed(Some(t.local_bounds()));
|
||||
let text = self.text.as_ref();
|
||||
for r in &mut t.runs {
|
||||
if let Some((cs, _)) = text.filter(|_| a.character) {
|
||||
// The character attributes; the paints follow the appearance attributes.
|
||||
let st = &mut r.style;
|
||||
*st = CharStyle {
|
||||
fill: std::mem::take(&mut st.fill),
|
||||
stroke: std::mem::take(&mut st.stroke),
|
||||
stroke_width: st.stroke_width,
|
||||
overprint_fill: st.overprint_fill,
|
||||
overprint_stroke: st.overprint_stroke,
|
||||
..cs.clone()
|
||||
};
|
||||
}
|
||||
run_attrs(&mut r.style, &placed, ap);
|
||||
}
|
||||
if let Some((_, para)) = text.filter(|_| a.paragraph) {
|
||||
t.para = para.clone();
|
||||
}
|
||||
if text.is_some() && a.character {
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let placed = self.placed(n.geometric_bounds());
|
||||
if ap.fill == FillAttrs::ALL && ap.stroke == StrokeAttrs::ALL {
|
||||
n.appearance = placed;
|
||||
} else {
|
||||
focal_attrs(&mut n.appearance, &placed, ap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The focal (topmost) fill and stroke attributes `a` of `from` onto `to`. Taking a colour creates
|
||||
/// a missing fill or stroke.
|
||||
fn focal_attrs(to: &mut Appearance, from: &Appearance, a: &AppearanceAttrs) {
|
||||
if a.fill.color {
|
||||
to.set_fill(from.fill_paint());
|
||||
}
|
||||
if let (Some(t), Some(f)) = (to.fill_mut(), from.fill()) {
|
||||
if a.fill.transparency {
|
||||
(t.opacity, t.blend) = (f.opacity, f.blend);
|
||||
}
|
||||
if a.fill.overprint {
|
||||
t.overprint = f.overprint;
|
||||
}
|
||||
}
|
||||
if a.stroke.color {
|
||||
to.set_stroke(from.stroke_paint());
|
||||
}
|
||||
if let (Some(t), Some(k)) = (to.stroke_mut(), from.stroke()) {
|
||||
let s = &a.stroke;
|
||||
if s.transparency {
|
||||
(t.opacity, t.blend) = (k.opacity, k.blend);
|
||||
}
|
||||
if s.overprint {
|
||||
t.overprint = k.overprint;
|
||||
}
|
||||
if s.weight {
|
||||
t.width = k.width;
|
||||
}
|
||||
if s.cap {
|
||||
t.cap = k.cap;
|
||||
}
|
||||
if s.join {
|
||||
t.join = k.join;
|
||||
}
|
||||
if s.miter {
|
||||
t.miter_limit = k.miter_limit;
|
||||
}
|
||||
if s.dash {
|
||||
t.dash = k.dash.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill and stroke attributes `a` of `from` onto a type run's characters (their stroke takes
|
||||
/// what characters have: paint, weight, cap, join, miter limit, dashes and overprint). Characters
|
||||
/// without a painted stroke get no weight, a painted one at least 1 pt.
|
||||
fn run_attrs(st: &mut CharStyle, from: &Appearance, a: &AppearanceAttrs) {
|
||||
let mut ap = st.basic_appearance();
|
||||
focal_attrs(&mut ap, from, a);
|
||||
let (Some(f), Some(k)) = (ap.fill(), ap.stroke()) else { return };
|
||||
(st.fill, st.overprint_fill, st.overprint_stroke) = (f.paint.clone(), f.overprint, k.overprint);
|
||||
let width = if a.stroke.color { ap.stroke_width() } else { k.width };
|
||||
st.set_stroke_layer(&StrokeLayer { width, ..k.clone() });
|
||||
if !st.stroke.is_none() {
|
||||
super::gradient::run_stroke_weight(st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,8 +355,8 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
|
||||
/// The Transparency panel's state ([`Session::transparency_info`]).
|
||||
pub use opacitymask::TransparencyInfo;
|
||||
|
||||
/// What the Eyedropper copies (`Session::eyedropper`).
|
||||
pub use xform::EyedropperOptions;
|
||||
/// Eyedropper Options (`Prefs::eyedropper`).
|
||||
pub use xform::{AppearanceAttrs, EyedropperAttrs, EyedropperOptions, FillAttrs, StrokeAttrs};
|
||||
|
||||
/// The Attributes panel's state ([`Session::attributes_info`]).
|
||||
pub use overprint::AttributesInfo;
|
||||
|
||||
@@ -300,6 +300,23 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
PREF_SPECS.iter().find(|s| s.key == key)
|
||||
}
|
||||
|
||||
/// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they
|
||||
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`). `prefs.get`
|
||||
/// and `prefs.set` take them by key (a partial object updates what it names) and `prefs.reset`
|
||||
/// without a category resets them.
|
||||
pub const PREF_GROUPS: &[&str] = &["eyedropper"];
|
||||
|
||||
/// Validate a value for preference group `key` against `current`.
|
||||
fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<Value, String> {
|
||||
match key {
|
||||
"eyedropper" => {
|
||||
let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default();
|
||||
cur.merged(v).map(|o| json!(o))
|
||||
}
|
||||
_ => Err(format!("unknown preference `{key}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate (and normalize) a value for `key`. Numbers may be given as strings; booleans as
|
||||
/// "true"/"false"; choices by value or label (case-insensitive).
|
||||
pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
|
||||
@@ -354,7 +371,7 @@ impl Prefs {
|
||||
pub fn set_values(&mut self, values: &Map<String, Value>) -> std::result::Result<(), String> {
|
||||
let mut obj = self.to_json();
|
||||
for (k, v) in values {
|
||||
obj[k.as_str()] = validate(k, v)?;
|
||||
obj[k.as_str()] = if PREF_GROUPS.contains(&k.as_str()) { validate_group(k, &obj[k.as_str()], v)? } else { validate(k, v)? };
|
||||
}
|
||||
*self = serde_json::from_value(obj).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
@@ -367,6 +384,11 @@ impl Prefs {
|
||||
for sp in PREF_SPECS.iter().filter(|s| category.is_none_or(|c| c.eq_ignore_ascii_case(s.category))) {
|
||||
obj[sp.key] = d[sp.key].clone();
|
||||
}
|
||||
if category.is_none() {
|
||||
for g in PREF_GROUPS {
|
||||
obj[*g] = d[*g].clone();
|
||||
}
|
||||
}
|
||||
if let Ok(p) = serde_json::from_value(obj) {
|
||||
*self = p;
|
||||
}
|
||||
@@ -404,13 +426,13 @@ impl Session {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference, or {prefs: {…all…}}", always, get),
|
||||
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get),
|
||||
cmd!(
|
||||
query "prefs.set",
|
||||
"Set Preference",
|
||||
[],
|
||||
None,
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list)",
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; the eyedropper group takes a partial object, as eyedropper.setOptions)",
|
||||
always,
|
||||
set
|
||||
),
|
||||
@@ -422,7 +444,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = s.prefs.to_json();
|
||||
match str_param(p, "key") {
|
||||
Some(k) if spec(k).is_some() => Ok(all[k].clone()),
|
||||
Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()),
|
||||
Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))),
|
||||
None => Ok(json!({ "prefs": all })),
|
||||
}
|
||||
|
||||
+154
-14
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper Options",
|
||||
[],
|
||||
None,
|
||||
"{appearance?: bool (fill, stroke and effects), transparency?: bool (opacity and blend mode)} what the Eyedropper copies (appearance.copyFrom); {} reads them → {appearance, transparency}",
|
||||
"{sampleSize?: 1|3|5 (pixels square averaged when sampling an image: point, 3×3, 5×5), pickUp?, apply?: {appearance?: {transparency?, fill?: {color?, transparency?, overprint?}, stroke?: {color?, transparency?, overprint?, weight?, cap?, join?, miter?, dash?}}, character?, paragraph?} (bools; a bool for a branch sets all of it)} Eyedropper Options, kept in the preferences: what appearance.copyFrom picks up from the clicked object and applies (only attributes in both trees are copied; every fill and stroke attribute copies the whole appearance stack); {} reads them → the options",
|
||||
always,
|
||||
eyedropper_options
|
||||
),
|
||||
@@ -171,33 +171,173 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- eyedropper ----------
|
||||
|
||||
/// What the Eyedropper copies from the object it clicks (`appearance.copyFrom`).
|
||||
/// The focal (topmost) fill's attributes the Eyedropper picks up or applies.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EyedropperOptions {
|
||||
/// The fill, stroke and effects (the appearance stack; type: its run paints).
|
||||
pub appearance: bool,
|
||||
/// The opacity and blend mode.
|
||||
#[serde(default)]
|
||||
pub struct FillAttrs {
|
||||
pub color: bool,
|
||||
/// Its own opacity and blend mode.
|
||||
pub transparency: bool,
|
||||
pub overprint: bool,
|
||||
}
|
||||
|
||||
impl FillAttrs {
|
||||
pub const ALL: Self = Self { color: true, transparency: true, overprint: true };
|
||||
}
|
||||
|
||||
impl Default for FillAttrs {
|
||||
fn default() -> Self {
|
||||
Self::ALL
|
||||
}
|
||||
}
|
||||
|
||||
/// The focal (topmost) stroke's attributes the Eyedropper picks up or applies.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct StrokeAttrs {
|
||||
pub color: bool,
|
||||
/// Its own opacity and blend mode.
|
||||
pub transparency: bool,
|
||||
pub overprint: bool,
|
||||
pub weight: bool,
|
||||
pub cap: bool,
|
||||
pub join: bool,
|
||||
/// The miter limit.
|
||||
pub miter: bool,
|
||||
/// The dash pattern.
|
||||
pub dash: bool,
|
||||
}
|
||||
|
||||
impl StrokeAttrs {
|
||||
pub const ALL: Self = Self { color: true, transparency: true, overprint: true, weight: true, cap: true, join: true, miter: true, dash: true };
|
||||
}
|
||||
|
||||
impl Default for StrokeAttrs {
|
||||
fn default() -> Self {
|
||||
Self::ALL
|
||||
}
|
||||
}
|
||||
|
||||
/// Appearance attributes: the object's transparency (opacity and blend mode) and its focal fill and
|
||||
/// stroke. With every fill and stroke attribute, the whole appearance stack (every fill and stroke,
|
||||
/// with their effects, and the object's effects) is copied.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AppearanceAttrs {
|
||||
pub transparency: bool,
|
||||
pub fill: FillAttrs,
|
||||
pub stroke: StrokeAttrs,
|
||||
}
|
||||
|
||||
impl Default for AppearanceAttrs {
|
||||
fn default() -> Self {
|
||||
Self { transparency: true, fill: FillAttrs::ALL, stroke: StrokeAttrs::ALL }
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the Eyedropper Options' trees: what it picks up, or what it applies.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct EyedropperAttrs {
|
||||
pub appearance: AppearanceAttrs,
|
||||
/// Type's character attributes (font, size, leading, tracking…; its paints follow `appearance`).
|
||||
pub character: bool,
|
||||
/// Type's paragraph attributes.
|
||||
pub paragraph: bool,
|
||||
}
|
||||
|
||||
impl Default for EyedropperAttrs {
|
||||
fn default() -> Self {
|
||||
Self { appearance: Default::default(), character: true, paragraph: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl EyedropperAttrs {
|
||||
/// No attribute at all.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
fn any(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Bool(b) => *b,
|
||||
Value::Object(o) => o.values().any(any),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
!any(&json!(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// Eyedropper Options (stored in the preferences): the raster sample size and what the Eyedropper
|
||||
/// picks up from the object it clicks and applies to the selection (`appearance.copyFrom`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EyedropperOptions {
|
||||
/// Pixels square averaged when sampling an image: 1 (point), 3 or 5.
|
||||
pub sample_size: u32,
|
||||
pub pick_up: EyedropperAttrs,
|
||||
pub apply: EyedropperAttrs,
|
||||
}
|
||||
|
||||
impl Default for EyedropperOptions {
|
||||
fn default() -> Self {
|
||||
Self { appearance: true, transparency: true }
|
||||
Self { sample_size: 1, pick_up: Default::default(), apply: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl EyedropperOptions {
|
||||
/// These options with any `appearance` / `transparency` booleans in `p` applied.
|
||||
pub(crate) fn with(mut self, p: &Value) -> Self {
|
||||
self.appearance = bool_or(p, "appearance", self.appearance);
|
||||
self.transparency = bool_or(p, "transparency", self.transparency);
|
||||
self
|
||||
/// These options with `sampleSize`, `pickUp` and `apply` from `p` applied: a tree given as an
|
||||
/// object sets the flags it names, a bool sets every flag of its branch.
|
||||
pub(crate) fn merged(&self, p: &Value) -> std::result::Result<Self, String> {
|
||||
let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?;
|
||||
if let Some(n) = p.get("sampleSize").filter(|n| !n.is_null()) {
|
||||
let n = n.as_u64().filter(|n| matches!(n, 1 | 3 | 5)).ok_or("`sampleSize` must be 1, 3 or 5")?;
|
||||
v["sampleSize"] = json!(n);
|
||||
}
|
||||
for key in ["pickUp", "apply"] {
|
||||
if let Some(t) = p.get(key).filter(|t| !t.is_null()) {
|
||||
merge_flags(&mut v[key], t, key)?;
|
||||
}
|
||||
}
|
||||
serde_json::from_value(v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What one copy takes: the attributes both picked up and applied, with the `pickUp` / `apply`
|
||||
/// params of that call merged over the options.
|
||||
pub(crate) fn attrs(&self, p: &Value) -> std::result::Result<EyedropperAttrs, String> {
|
||||
let o = self.merged(p)?;
|
||||
let mut both = json!(o.pick_up);
|
||||
and_flags(&mut both, &json!(o.apply));
|
||||
serde_json::from_value(both).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the flags of `dst` named by `src` (`path`: where `dst` is, for errors).
|
||||
fn merge_flags(dst: &mut Value, src: &Value, path: &str) -> std::result::Result<(), String> {
|
||||
match (dst, src) {
|
||||
(Value::Bool(d), Value::Bool(b)) => {
|
||||
*d = *b;
|
||||
Ok(())
|
||||
}
|
||||
(Value::Object(d), Value::Bool(_)) => d.values_mut().try_for_each(|v| merge_flags(v, src, path)),
|
||||
(Value::Object(d), Value::Object(s)) => s.iter().try_for_each(|(k, v)| match d.get_mut(k) {
|
||||
Some(dv) => merge_flags(dv, v, &format!("{path}.{k}")),
|
||||
None => Err(format!("unknown option `{path}.{k}`")),
|
||||
}),
|
||||
_ => Err(format!("`{path}` must be a bool or an object of bools")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `dst` and `other` flag by flag.
|
||||
fn and_flags(dst: &mut Value, other: &Value) {
|
||||
match (dst, other) {
|
||||
(Value::Bool(d), Value::Bool(b)) => *d &= *b,
|
||||
(Value::Object(d), Value::Object(o)) => d.iter_mut().for_each(|(k, v)| and_flags(v, &o[k])),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn eyedropper_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.eyedropper = s.eyedropper.with(p);
|
||||
Ok(serde_json::to_value(s.eyedropper).unwrap_or_default())
|
||||
s.prefs.eyedropper = s.prefs.eyedropper.merged(p).map_err(|e| bad("eyedropper.setOptions", e))?;
|
||||
Ok(json!(s.prefs.eyedropper))
|
||||
}
|
||||
|
||||
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -314,6 +314,9 @@ pub struct Prefs {
|
||||
/// Object → Pattern → Tile Edge Color (`#rrggbb`): the tile edge and swatch bounds in pattern
|
||||
/// editing mode.
|
||||
pub pattern_tile_edge_color: String,
|
||||
/// Eyedropper Options (`eyedropper.setOptions`; a preference group, see
|
||||
/// [`cmd::prefscmds::PREF_GROUPS`]).
|
||||
pub eyedropper: EyedropperOptions,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -431,6 +434,7 @@ impl Default for Prefs {
|
||||
let [r, g, b] = vectorcraft_doc::LAYER_COLORS[0].1;
|
||||
Color::rgb8(r, g, b).to_hex()
|
||||
},
|
||||
eyedropper: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -473,8 +477,6 @@ pub struct Session {
|
||||
pub recent_colors: Vec<Color>,
|
||||
/// A paint applied by a live preview: remembered when the interaction commits.
|
||||
pub(crate) pending_paint: Option<Paint>,
|
||||
/// What the Eyedropper copies (`eyedropper.setOptions`); not saved.
|
||||
pub eyedropper: EyedropperOptions,
|
||||
/// User Defined and loaded swatch libraries (Window → Swatch Libraries); not saved.
|
||||
pub swatch_libraries: cmd::swatchlib::Libraries,
|
||||
/// The selected freeform gradient point (`paint.freeform.selectPoint`) and whose gradient it
|
||||
@@ -511,7 +513,6 @@ impl Session {
|
||||
last_gradient: GradientPaint::new(Default::default()),
|
||||
recent_colors: vec![],
|
||||
pending_paint: None,
|
||||
eyedropper: Default::default(),
|
||||
swatch_libraries: Default::default(),
|
||||
freeform_point: None,
|
||||
}
|
||||
@@ -786,6 +787,8 @@ mod tests_editcolors;
|
||||
#[cfg(test)]
|
||||
mod tests_effectedit;
|
||||
#[cfg(test)]
|
||||
mod tests_eyedropper;
|
||||
#[cfg(test)]
|
||||
mod tests_file;
|
||||
#[cfg(test)]
|
||||
mod tests_flatten;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Eyedropper Options and modes (M3.52): the Picks Up / Applies trees, type attributes, Alt-click
|
||||
//! (reverse) and Shift+Alt-click (append), image pixels, colour models and the preference.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, Justify, LineCap, Node, NodeKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
id_of(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64, mods: Mods) {
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(mods), ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
/// A red-filled source with a 5 pt round-capped blue stroke at 50 % opacity, and a selected
|
||||
/// target with the default white fill and a 2 pt black stroke.
|
||||
fn source_and_target(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let src = rect(s, 300.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 5, "cap": "round"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
let dst = rect(s, 10.0);
|
||||
s.execute("paint.default", &json!({})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
|
||||
(src, dst)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_up_without_weight_keeps_the_target_weight() {
|
||||
let mut s = session();
|
||||
let (src, dst) = source_and_target(&mut s);
|
||||
s.execute("eyedropper.setOptions", &json!({"pickUp": {"appearance": {"stroke": {"weight": false}}}})).unwrap();
|
||||
let weight = s.paint.stroke_width;
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let n = node(&s, dst);
|
||||
let st = n.appearance.stroke().unwrap();
|
||||
assert_eq!((st.paint.color().unwrap().to_hex(), st.width, st.cap), ("#0000ff".into(), 2.0, LineCap::Round));
|
||||
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(n.opacity, 0.5);
|
||||
assert_eq!(s.paint.stroke_width, weight, "new art keeps its weight too");
|
||||
// Applying no transparency (one call) keeps the target's opacity; undo is one step per copy.
|
||||
s.execute("transparency.set", &json!({"opacity": 100, "ids": [dst.0]})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap();
|
||||
assert_eq!((node(&s, dst).opacity, s.doc().unwrap().history.undo.len()), (1.0, undo + 1));
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"nope": true}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_appearance_attribute_copies_the_whole_stack() {
|
||||
let mut s = session();
|
||||
let (src, dst) = source_and_target(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
assert_eq!(node(&s, dst).appearance, node(&s, src).appearance);
|
||||
// Without the fill's overprint only the focal attributes go: the target keeps its two items.
|
||||
let dst2 = rect(&mut s, 150.0);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"fill": {"overprint": false}}}})).unwrap();
|
||||
assert_eq!(node(&s, dst2).appearance.items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_picks_up_font_and_size() {
|
||||
let mut s = session();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Source"})).unwrap());
|
||||
s.execute("text.setStyle", &json!({"font": "Inter", "size": 30, "tracking": 50, "justify": "center", "fill": "#00ff00"})).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 10, "y": 400, "text": "Target"})).unwrap());
|
||||
let style = |s: &Session, id| match &node(s, id).kind {
|
||||
NodeKind::Text(t) => (t.first_style(), t.para.justify),
|
||||
_ => panic!("not type"),
|
||||
};
|
||||
let width = |s: &Session, id| node(s, id).geometric_bounds().unwrap().width();
|
||||
let before = width(&s, dst);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let (st, justify) = style(&s, dst);
|
||||
assert_eq!((st.font_family.as_str(), st.size, st.tracking, justify), ("Inter", 30.0, 50.0, Justify::Center));
|
||||
assert_eq!(st.fill.color().unwrap().to_hex(), "#00ff00");
|
||||
assert!(width(&s, dst) > before, "laid out again at the new size");
|
||||
// Without character or paragraph attributes only the paints go.
|
||||
let dst2 = id_of(s.execute("text.create", &json!({"x": 10, "y": 500, "text": "Other"})).unwrap());
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"character": false, "paragraph": false}})).unwrap();
|
||||
let (st, justify) = style(&s, dst2);
|
||||
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, Justify::Left, "#00ff00".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_applies_the_selection_to_the_clicked_object() {
|
||||
let mut s = session();
|
||||
let (src, dst) = source_and_target(&mut s);
|
||||
// `dst` is selected; Alt-click `src`: it takes the selection's white fill and 2 pt stroke.
|
||||
let defaults = s.paint.clone();
|
||||
click(&mut s, 325.0, 35.0, Mods { alt: true, ..Default::default() });
|
||||
let n = node(&s, src);
|
||||
assert_eq!((n.appearance.fill_paint(), n.appearance.stroke_width()), (Paint::solid(Color::WHITE), 2.0));
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint(), Paint::solid(Color::WHITE), "the selection is unchanged");
|
||||
assert_eq!(s.paint, defaults, "the defaults stay");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "reverse": true})).is_err(), "nothing to copy from");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_alt_click_appends_the_appearance() {
|
||||
let mut s = session();
|
||||
let (_, dst) = source_and_target(&mut s);
|
||||
click(&mut s, 325.0, 35.0, Mods { alt: true, shift: true, ..Default::default() });
|
||||
let items = node(&s, dst).appearance.items;
|
||||
assert_eq!(items.len(), 4, "the source's fill and stroke on top of the target's");
|
||||
assert_eq!((items[0].paint().color().unwrap().to_hex(), items[2].paint().color().unwrap().to_hex()), ("#ffffff".into(), "#ff0000".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_stays_cmyk() {
|
||||
let mut s = session();
|
||||
let src = rect(&mut s, 300.0);
|
||||
let cmyk = Color::cmyk(0.1, 0.5, 0.9, 0.2);
|
||||
s.execute("paint.setFill", &json!({"color": {"c": 0.1, "m": 0.5, "y": 0.9, "k": 0.2}})).unwrap();
|
||||
let dst = rect(&mut s, 10.0);
|
||||
click(&mut s, 325.0, 35.0, Mods { shift: true, ..Default::default() });
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(cmyk), "a sampled colour keeps its model");
|
||||
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
|
||||
click(&mut s, 325.0, 35.0, Mods::default());
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(cmyk));
|
||||
let _ = src;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_an_image_samples_its_pixel() {
|
||||
let mut s = session();
|
||||
// Two pixels, orange and teal, 100 pt each, at (300, 0).
|
||||
let img = image::RgbaImage::from_raw(2, 1, [[255, 128, 0, 255], [0, 128, 128, 255]].concat()).unwrap();
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, _| {
|
||||
d.images.insert("px".into(), ImageBlob::png(png));
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0);
|
||||
let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None };
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).map_err(|e| crate::EngineError::Other(e.to_string()))
|
||||
})
|
||||
.unwrap();
|
||||
let dst = rect(&mut s, 10.0);
|
||||
click(&mut s, 450.0, 50.0, Mods::default());
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint().color().unwrap().to_hex(), "#008080");
|
||||
s.execute("eyedropper.setOptions", &json!({"sampleSize": 5})).unwrap();
|
||||
click(&mut s, 350.0, 50.0, Mods::default());
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint().color().unwrap().to_hex(), "#808040", "5 x 5 average of both pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_options_are_a_preference() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("eyedropper.setOptions", &json!({"sampleSize": 4})).is_err());
|
||||
assert!(s.execute("eyedropper.setOptions", &json!({"apply": {"character": 1}})).is_err());
|
||||
s.execute("eyedropper.setOptions", &json!({"sampleSize": 3, "apply": {"paragraph": false}})).unwrap();
|
||||
let got = s.execute("prefs.get", &json!({"key": "eyedropper"})).unwrap();
|
||||
assert_eq!((&got["sampleSize"], &got["apply"]["paragraph"], &got["pickUp"]["paragraph"]), (&json!(3), &json!(false), &json!(true)));
|
||||
s.execute("prefs.set", &json!({"key": "eyedropper", "value": {"pickUp": {"appearance": false}}})).unwrap();
|
||||
assert!(!s.prefs.eyedropper.pick_up.appearance.fill.color && s.prefs.eyedropper.sample_size == 3);
|
||||
// Saved with the preferences; older preferences (no options) load the defaults.
|
||||
let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
|
||||
assert_eq!(back, s.prefs);
|
||||
let mut old = Prefs::default().to_json();
|
||||
old.as_object_mut().unwrap().remove("eyedropper");
|
||||
assert_eq!(serde_json::from_value::<Prefs>(old).unwrap().eyedropper, EyedropperOptions::default());
|
||||
s.execute("prefs.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.prefs.eyedropper, EyedropperOptions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_takes_the_stroke_options_it_has() {
|
||||
let mut s = session();
|
||||
let (src, _) = source_and_target(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"dash": [4, 2]})).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 10, "y": 400, "text": "Target"})).unwrap());
|
||||
s.execute("eyedropper.setOptions", &json!({"apply": {"appearance": {"stroke": {"dash": false}}}})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let NodeKind::Text(t) = &node(&s, dst).kind else { panic!("not type") };
|
||||
let st = t.first_style();
|
||||
assert_eq!((st.stroke.color().unwrap().to_hex(), st.stroke_width, st.stroke_cap), ("#0000ff".into(), 5.0, LineCap::Round));
|
||||
assert!(st.stroke_dash.is_none(), "the dash pattern isn't applied");
|
||||
}
|
||||
@@ -138,17 +138,20 @@ fn eyedropper_options_choose_what_copy_from_takes() {
|
||||
s.execute("transparency.set", &json!({"opacity": 40})).unwrap();
|
||||
let dst = rect(&mut s, 200.0, 10.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
|
||||
assert_eq!(s.execute("eyedropper.setOptions", &json!({})).unwrap(), json!({"appearance": true, "transparency": true}));
|
||||
assert_eq!(s.execute("eyedropper.setOptions", &json!({"appearance": false})).unwrap(), json!({"appearance": false, "transparency": true}));
|
||||
let o = s.execute("eyedropper.setOptions", &json!({})).unwrap();
|
||||
assert_eq!((&o["sampleSize"], &o["pickUp"]["appearance"]["fill"]["color"]), (&json!(1), &json!(true)));
|
||||
let o = s.execute("eyedropper.setOptions", &json!({"pickUp": {"appearance": {"fill": false, "stroke": false}}})).unwrap();
|
||||
assert_eq!((&o["pickUp"]["appearance"]["fill"]["overprint"], &o["apply"]["appearance"]["fill"]["color"]), (&json!(false), &json!(true)));
|
||||
let n = |s: &Session| s.doc().unwrap().doc.node(dst).unwrap().clone();
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap();
|
||||
assert_eq!((n(&s).opacity, n(&s).appearance.fill_paint().color().unwrap().to_hex()), (0.4, "#ffffff".into()));
|
||||
// Params override the options for one call.
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "appearance": true})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "pickUp": {"appearance": {"fill": true}}})).unwrap();
|
||||
assert_eq!(n(&s).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
|
||||
// Nothing to copy: no edit.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "transparency": false})).unwrap(), json!({"ids": []}));
|
||||
let r = s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "apply": false})).unwrap();
|
||||
assert_eq!(r, json!({"ids": []}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, validate};
|
||||
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate};
|
||||
|
||||
#[test]
|
||||
fn every_spec_matches_a_prefs_field_and_back() {
|
||||
@@ -16,7 +16,7 @@ fn every_spec_matches_a_prefs_field_and_back() {
|
||||
assert!(validate(sp.key, &obj[sp.key]).is_ok(), "default of `{}` fails validation", sp.key);
|
||||
}
|
||||
for k in obj.keys() {
|
||||
assert!(PREF_SPECS.iter().any(|s| s.key == k), "Prefs field `{k}` has no spec");
|
||||
assert!(PREF_SPECS.iter().any(|s| s.key == k) || PREF_GROUPS.contains(&k.as_str()), "Prefs field `{k}` has no spec");
|
||||
}
|
||||
for c in PREF_CATEGORIES {
|
||||
assert!(PREF_SPECS.iter().any(|s| s.category == *c), "category {c} is empty");
|
||||
|
||||
@@ -83,6 +83,7 @@ impl Session {
|
||||
appearance_item: self.appearance_item(),
|
||||
constrain_angle: self.prefs.constrain_angle,
|
||||
freeform_point: self.selected_freeform_point(),
|
||||
raster_sample: self.prefs.eyedropper.sample_size,
|
||||
};
|
||||
f(self.tool.as_mut(), &cx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user