Merge pull request #42 from storytold/pr/m3-proxies-eyedropper
M3: Fill/Stroke chip popovers, panel shortcuts, Eyedropper Options and modes (M3.51, M3.52)
This commit is contained in:
commit
a2adf729a7
|
|
@ -4301,6 +4301,7 @@ dependencies = [
|
|||
name = "vectorcraft-tools"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -574,7 +574,8 @@ impl Node {
|
|||
let b = t.local_bounds();
|
||||
return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) });
|
||||
}
|
||||
let b = self.geometric_bounds()?;
|
||||
// An object without bounds (an empty path) still shows its paints, fitted to an empty box.
|
||||
let b = self.geometric_bounds().unwrap_or(Rect::ZERO);
|
||||
if stroke {
|
||||
self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b)))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,48 @@ impl ImageBlob {
|
|||
}
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// The colour around pixel (`x`, `y`) (pixel space): the alpha-weighted average of the `size` ×
|
||||
/// `size` pixels centred on it, clipped to the image, as straight RGBA. `None` outside the
|
||||
/// image, where every pixel is transparent, or when the image can't be decoded. The last image
|
||||
/// decoded is kept for the next sample (the Eyedropper samples one image click after click).
|
||||
pub fn sample(&self, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
|
||||
thread_local! {
|
||||
static LAST: std::cell::RefCell<Option<(String, Arc<image::RgbaImage>)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
let key = self.content_key();
|
||||
let img = LAST.with_borrow_mut(|last| match last {
|
||||
Some((k, img)) if *k == key => Some(img.clone()),
|
||||
_ => {
|
||||
let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8());
|
||||
*last = Some((key, img.clone()));
|
||||
Some(img)
|
||||
}
|
||||
})?;
|
||||
let (w, h) = (img.width() as i64, img.height() as i64);
|
||||
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
|
||||
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
|
||||
return None;
|
||||
}
|
||||
let r = size.max(1) as i64 / 2;
|
||||
let (mut sum, mut alpha, mut n) = ([0u64; 3], 0u64, 0u64);
|
||||
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
|
||||
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
|
||||
let [pr, pg, pb, pa] = img.get_pixel(px as u32, py as u32).0;
|
||||
let a = pa as u64;
|
||||
sum = [sum[0] + pr as u64 * a, sum[1] + pg as u64 * a, sum[2] + pb as u64 * a];
|
||||
alpha += a;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if alpha == 0 {
|
||||
return None;
|
||||
}
|
||||
let c = |v: u64| ((v + alpha / 2) / alpha) as u8;
|
||||
Some([c(sum[0]), c(sum[1]), c(sum[2]), ((alpha + n / 2) / n) as u8])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -57,6 +99,17 @@ mod tests {
|
|||
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_a_pixel_or_the_average_around_it() {
|
||||
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
|
||||
assert_eq!(b.sample(1.5, 0.5, 1), Some([0, 255, 0, 255]));
|
||||
assert_eq!(b.sample(1.5, 0.5, 3), Some([85, 85, 85, 255]), "red, green and blue averaged");
|
||||
assert_eq!(b.sample(3.2, 0.5, 3), Some([0, 0, 255, 128]), "transparent pixels don't count towards the colour");
|
||||
assert_eq!(b.sample(3.2, 0.5, 1), None, "all transparent");
|
||||
assert_eq!(b.sample(-0.5, 0.5, 5), None, "outside");
|
||||
assert_eq!(b.sample(0.0, 0.0, 5), Some([85, 85, 85, 255]), "clipped to the image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_each_colour_once_and_keeps_alpha() {
|
||||
let b = blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -41,10 +41,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
default_paint
|
||||
),
|
||||
cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| {
|
||||
s.fill_active = !s.fill_active;
|
||||
Ok(json!({ "fillActive": s.fill_active }))
|
||||
}),
|
||||
cmd!(
|
||||
"paint.toggleActive",
|
||||
"Toggle Fill/Stroke Focus",
|
||||
[],
|
||||
Some("X"),
|
||||
"{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}",
|
||||
always,
|
||||
|s, p| {
|
||||
s.fill_active = p.get("fill").and_then(Value::as_bool).unwrap_or(!s.fill_active);
|
||||
Ok(json!({ "fillActive": s.fill_active }))
|
||||
}
|
||||
),
|
||||
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| {
|
||||
let f = s.fill_active;
|
||||
set_paint(s, &json!({"none": true}), f)
|
||||
|
|
@ -69,18 +77,18 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
|||
"Invert",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; with nothing selected, invert the default → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "Invert", &super::colorcmds::invert)
|
||||
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
|
||||
),
|
||||
cmd!(
|
||||
"paint.complement",
|
||||
"Complement",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; with nothing selected, the default → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "Complement", &|c: Color| c.complement_keep_model())
|
||||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||||
),
|
||||
cmd!(
|
||||
"paint.lastColor",
|
||||
|
|
@ -231,36 +239,44 @@ pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
|||
|
||||
impl DocState {
|
||||
/// Whether the selected objects' fills and strokes differ as the proxies show them (a "?"
|
||||
/// proxy; a group whose contents differ counts). One walk of the document, so callers that ask
|
||||
/// proxy; a group whose contents differ counts). `item` (the Appearance panel's active item)
|
||||
/// stands for the proxy of its kind, as in [`Session::proxy_paints`]: that fill or stroke of
|
||||
/// each selected object's own stack is compared. One walk of the document, so callers that ask
|
||||
/// every frame cache it by revision.
|
||||
pub fn proxy_mixed(&self) -> (bool, bool) {
|
||||
pub fn proxy_mixed(&self, item: Option<usize>) -> (bool, bool) {
|
||||
if self.selection.is_empty() {
|
||||
return (false, false);
|
||||
}
|
||||
let ids: std::collections::HashSet<NodeId> = self.selection.objects.iter().copied().collect();
|
||||
let mut nodes = vec![];
|
||||
let (mut selected, mut nodes) = (vec![], vec![]);
|
||||
self.doc.walk(|n| {
|
||||
if ids.contains(&n.id) {
|
||||
selected.push(n);
|
||||
painted(n, true, &mut nodes);
|
||||
}
|
||||
});
|
||||
let mut paints = nodes.iter().map(|n| (proxy_paint(n, false, None), proxy_paint(n, true, None)));
|
||||
let Some((f0, s0)) = paints.next() else { return (false, false) };
|
||||
let mut mixed = (false, false);
|
||||
for (f, s) in paints {
|
||||
mixed.0 |= !same_in_proxy(&f0, &f);
|
||||
mixed.1 |= !same_in_proxy(&s0, &s);
|
||||
if mixed.0 && mixed.1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
mixed
|
||||
let first = self.selection.objects.first().and_then(|id| self.doc.node(*id));
|
||||
let differ = |stroke: bool| {
|
||||
let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke));
|
||||
let set = if item.is_some() { &selected } else { &nodes };
|
||||
let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item));
|
||||
let Some(p0) = paints.next() else { return false };
|
||||
paints.any(|p| !same_in_proxy(&p0, &p))
|
||||
};
|
||||
(differ(false), differ(true))
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item.
|
||||
pub fn proxy_mixed(&self) -> (bool, bool) {
|
||||
self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item()))
|
||||
}
|
||||
}
|
||||
|
||||
fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (fill, stroke) = s.proxy_paints();
|
||||
let (fill_mixed, stroke_mixed) = s.active().map_or((false, false), DocState::proxy_mixed);
|
||||
let (fill_mixed, stroke_mixed) = s.proxy_mixed();
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed}))
|
||||
}
|
||||
|
||||
|
|
@ -428,9 +444,10 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
/// Invert / Complement: recolour the active proxy of the targets (or the default when there are
|
||||
/// none) and remember the first target's new paint.
|
||||
fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
/// Invert / Complement (`cmd`, undone as `label`): recolour the active proxy of the targets (or the
|
||||
/// default when there are none) and remember the first target's new paint. The Appearance panel's
|
||||
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
|
||||
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
let stroke = stroke_param(s, p);
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
|
|
@ -443,11 +460,27 @@ fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> C
|
|||
s.remember_paint(&shown);
|
||||
return Ok(json!({ "changed": changed as usize }));
|
||||
}
|
||||
// The proxy's colours only: images and pattern tiles stay as they are.
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
let r = super::colorcmds::recolor(s, &q, label, f)?;
|
||||
let first = leaf_targets(s, &ids)?.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, None)));
|
||||
if let Some(shown) = first {
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
|
||||
let (r, shown_item) = match item {
|
||||
ItemTarget::Item { index, .. } => {
|
||||
let ids = item.targets(s, p)?;
|
||||
let mut changed = 0;
|
||||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||||
let paint =
|
||||
if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) };
|
||||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||||
Ok(())
|
||||
})?;
|
||||
(json!({ "changed": changed }), Some(index))
|
||||
}
|
||||
// The proxy's colours only: images and pattern tiles stay as they are.
|
||||
ItemTarget::Top => {
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
(super::colorcmds::recolor(s, &q, label, f)?, None)
|
||||
}
|
||||
};
|
||||
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
|
||||
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
|
||||
s.remember_paint(&shown);
|
||||
}
|
||||
Ok(r)
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -820,6 +823,8 @@ mod tests_pattern;
|
|||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ fn a_mixed_selection_shows_a_question_mark_proxy() {
|
|||
assert_eq!(q(&mut s)["fillMixed"], false);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false));
|
||||
assert_eq!(s.proxy_mixed(), (false, false));
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(q(&mut s)["fillMixed"], false);
|
||||
}
|
||||
|
|
@ -209,7 +209,7 @@ fn every_selection_change_bumps_the_revision() {
|
|||
s.execute("select.magicWand", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2, "{a:?} and {b:?} are within the tolerance");
|
||||
assert!(s.doc().unwrap().revision > rev);
|
||||
assert_eq!(s.doc().unwrap().proxy_mixed(), (true, false));
|
||||
assert_eq!(s.proxy_mixed(), (true, false));
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "Hi"})).unwrap();
|
||||
let family = match &node(&s, NodeId(r["id"].as_u64().unwrap())).kind {
|
||||
NodeKind::Text(t) => t.first_style().font_family,
|
||||
|
|
@ -265,7 +265,7 @@ fn a_group_shows_its_contents_in_the_proxies() {
|
|||
// The group shows what the paint commands change: its contents' red fill, not mixed.
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 1);
|
||||
assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false));
|
||||
assert_eq!(s.proxy_mixed(), (false, false));
|
||||
// Contents that differ make even a single selected group a "?" proxy.
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap();
|
||||
assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
//! The Fill/Stroke proxies and the Appearance panel's active item: Invert / Complement and the "?"
|
||||
//! state follow the active fill or stroke, objects without bounds still show their paints, and the
|
||||
//! proxy focus can be set directly (M3.51).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A selected rectangle with a red fill below its stroke and a blue fill on top: [Fill, Stroke, Fill].
|
||||
fn two_fills(s: &mut Session, x: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"index": 2, "color": "#0000ff"})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn item_hex(s: &Session, id: NodeId, index: usize) -> String {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.items[index].paint().color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_and_complement_recolour_the_active_item() {
|
||||
let mut s = session();
|
||||
let id = two_fills(&mut s, 10.0);
|
||||
// Without an active item the proxy's colours go (every fill, as Edit Colors does).
|
||||
s.execute("paint.invert", &json!({})).unwrap();
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#00ffff".into(), "#ffff00".into()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// The bottom fill is the Appearance panel's active row: only it changes, as one undo step.
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1}));
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#00ffff".into(), "#0000ff".into()));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#00ffff", "the proxy shows the active row");
|
||||
assert_eq!(s.recent_colors[0].to_hex(), "#00ffff");
|
||||
s.execute("paint.complement", &json!({})).unwrap();
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
|
||||
// The Stroke proxy isn't the active row's kind: the stroke is complemented as usual.
|
||||
s.execute("paint.complement", &json!({"stroke": true})).unwrap();
|
||||
assert_eq!(item_hex(&s, id, 1), "#000000", "black's complement is black");
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mixed_proxy_compares_the_active_item() {
|
||||
let mut s = session();
|
||||
let a = two_fills(&mut s, 10.0);
|
||||
let b = two_fills(&mut s, 100.0);
|
||||
// Same top fills, different bottom fills.
|
||||
s.execute("appearance.setItem", &json!({"index": 0, "color": "#00ff00", "ids": [b.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
assert_eq!(s.proxy_mixed(), (false, false));
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.proxy_mixed(), (true, false), "the active bottom fills differ");
|
||||
assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true);
|
||||
// A stroke row leaves the fill proxy on the top fills.
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s.proxy_mixed(), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_without_bounds_shows_its_paints() {
|
||||
let mut s = session();
|
||||
let id = s
|
||||
.edit("Add", |d, _| {
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let ap = Appearance::basic(Paint::solid(Color::from_hex("#123456").unwrap()), Paint::solid(Color::from_hex("#654321").unwrap()), 2.0);
|
||||
d.insert(Some(l), 0, Node::path(id, PathData::default(), ap)).map_err(|e| crate::EngineError::Other(e.to_string()))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().is_none());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let (f, st) = s.proxy_paints();
|
||||
assert_eq!((f.color().unwrap().to_hex(), st.color().unwrap().to_hex()), ("#123456".into(), "#654321".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_active_can_name_the_proxy() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(), json!({"fillActive": false}));
|
||||
assert_eq!(s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(), json!({"fillActive": false}), "already in front");
|
||||
assert_eq!(s.execute("paint.toggleActive", &json!({})).unwrap(), json!({"fillActive": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_colour_keeps_its_model() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"c": 0.1, "m": 0.9, "y": 0.2, "k": 0.0}})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.lastColor", &json!({})).unwrap();
|
||||
assert_eq!(s.proxy_paints().0.color(), Some(Color::cmyk(0.1, 0.9, 0.2, 0.0)));
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,3 +17,6 @@ vectorcraft-text = { workspace = true }
|
|||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
image = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -168,6 +168,9 @@ pub struct ToolContext<'a> {
|
|||
/// The selected freeform gradient point (`paint.freeform.selectPoint`), marked on the
|
||||
/// freeform annotator.
|
||||
pub freeform_point: Option<usize>,
|
||||
/// Eyedropper Options' raster sample size: the pixels square averaged when the Eyedropper
|
||||
/// samples an image (1, 3 or 5).
|
||||
pub raster_sample: u32,
|
||||
}
|
||||
|
||||
impl ToolContext<'_> {
|
||||
|
|
@ -353,6 +356,7 @@ pub(crate) mod testutil {
|
|||
appearance_item: None,
|
||||
constrain_angle: 0.0,
|
||||
freeform_point: None,
|
||||
raster_sample: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
//! Eyedropper tool (I).
|
||||
//!
|
||||
//! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the
|
||||
//! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into
|
||||
//! the active fill/stroke (`paint.sampleColor`).
|
||||
//! Click an object: copy its attributes (what Eyedropper Options pick up and apply) to the
|
||||
//! selection and the paint defaults (`appearance.copyFrom`); Alt-click: the selection's attributes
|
||||
//! onto the clicked object (`reverse`); Shift+Alt-click: add the clicked object's appearance to the
|
||||
//! selection's (`append`). Shift-click: sample only the colour under the cursor into the active
|
||||
//! fill/stroke (`paint.sampleColor`, keeping the colour's model). Clicking an image samples its
|
||||
//! pixels (averaged over the options' raster sample size).
|
||||
//!
|
||||
//! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click
|
||||
//! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor
|
||||
|
|
@ -11,9 +14,11 @@
|
|||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::paint_owner;
|
||||
use crate::params::color_json;
|
||||
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -36,6 +41,26 @@ pub fn paint_color_at(paint: &Paint, bounds: Option<vectorcraft_geom::Rect>, p:
|
|||
}
|
||||
}
|
||||
|
||||
/// The colour of `n`'s fill (or its stroke, when the stroke was hit or there is no fill) at `p`,
|
||||
/// sampled in the paint's own space (text space for type runs) and box.
|
||||
fn paint_sample(n: &Node, on_stroke: bool, p: Point) -> Option<Color> {
|
||||
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
|
||||
let (paint, to_doc, bounds) = n.proxy_paint(on_stroke || fill_none, None)?;
|
||||
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * p } else { p };
|
||||
paint_color_at(paint, Some(bounds), p)
|
||||
}
|
||||
|
||||
/// The colour of image `n`'s pixels at `p`, averaged over the raster sample size.
|
||||
fn image_color_at(cx: &ToolContext, n: &Node, p: Point) -> Option<Color> {
|
||||
let NodeKind::Image(im) = &n.kind else { return None };
|
||||
if im.xf.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let px = im.xf.inverse() * p;
|
||||
let [r, g, b, _] = cx.doc.images.get(&im.key)?.sample(px.x, px.y, cx.raster_sample)?;
|
||||
Some(Color::rgb8(r, g, b))
|
||||
}
|
||||
|
||||
impl Tool for EyedropperTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"eyedropper"
|
||||
|
|
@ -50,18 +75,21 @@ impl Tool for EyedropperTool {
|
|||
let src = paint_owner(cx.doc, h.leaf);
|
||||
let Some(n) = cx.doc.node(src) else { return vec![] };
|
||||
let stop = self.stop.as_ref().zip(cx.gradient_stop);
|
||||
if ev.mods.shift || stop.is_some() {
|
||||
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
|
||||
let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none, None) else { return vec![] };
|
||||
// Sample in the paint's own space (text space for type runs) and box.
|
||||
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos };
|
||||
let Some(c) = paint_color_at(paint, Some(bounds), p) else { return vec![] };
|
||||
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))] };
|
||||
let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex(), "stop": i })), Action::SwitchTool(back.clone())];
|
||||
self.stop = None;
|
||||
return out;
|
||||
if ev.mods.alt && stop.is_none() {
|
||||
let how = if ev.mods.shift { "append" } else { "reverse" };
|
||||
return vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0, how: true }))];
|
||||
}
|
||||
vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))]
|
||||
let image = matches!(n.kind, NodeKind::Image(_));
|
||||
if !(ev.mods.shift || stop.is_some() || image) {
|
||||
return vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))];
|
||||
}
|
||||
let sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, h.kind == HitKind::Stroke, ev.pos) };
|
||||
let Some(c) = sampled else { return vec![] };
|
||||
let color = color_json(&c);
|
||||
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color }))] };
|
||||
let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color, "stop": i })), Action::SwitchTool(back.clone())];
|
||||
self.stop = None;
|
||||
out
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
|
|
@ -96,10 +124,10 @@ mod tests {
|
|||
assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#ffffff"}))]);
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::WHITE)}))]);
|
||||
// On the stroke edge → the stroke colour.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000"}))]);
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::BLACK)}))]);
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -119,10 +147,68 @@ mod tests {
|
|||
// Empty canvas: still waiting.
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000", "stop": 1})), Action::SwitchTool("gradient".into())]);
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![
|
||||
Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::BLACK), "stop": 1})),
|
||||
Action::SwitchTool("gradient".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(t.options(), json!({"stop": null}), "one sample only");
|
||||
}
|
||||
|
||||
/// The hex of the colour a `paint.sampleColor` action samples.
|
||||
fn sampled_hex(a: &[Action]) -> String {
|
||||
let [Action::Exec(c, p)] = a else { panic!("one action: {a:?}") };
|
||||
assert_eq!(c, "paint.sampleColor");
|
||||
let v: Vec<f32> = serde_json::from_value(p["color"].clone()).unwrap();
|
||||
Color::rgb(v[0], v[1], v[2]).to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_sends_reverse_and_shift_alt_append() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut t = EyedropperTool::default();
|
||||
let at = |m: Mods| PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(m);
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
assert_eq!(t.pointer(&cx(&d, &s, &p), &at(alt)), vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0, "reverse": true}))]);
|
||||
let both = Mods { alt: true, shift: true, ..Default::default() };
|
||||
assert_eq!(t.pointer(&cx(&d, &s, &p), &at(both)), vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0, "append": true}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_keeps_the_colour_model() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
|
||||
d.node_mut(id).unwrap().appearance.set_fill(Paint::solid(cmyk));
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&cmyk)}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_an_image_samples_its_pixels() {
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
// Three pixels, red, green, blue, 10 pt each.
|
||||
let img = image::RgbaImage::from_raw(3, 1, [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]].concat()).unwrap();
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("px".into(), ImageBlob::png(png));
|
||||
let im = ImageObject { key: "px".into(), width: 3, height: 1, xf: vectorcraft_geom::Affine::scale(10.0), link: None };
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
let click = PointerEvent::new(PointerKind::Down, 15.0, 5.0);
|
||||
assert_eq!(sampled_hex(&EyedropperTool::default().pointer(&cx, &click)), "#00ff00");
|
||||
cx.raster_sample = 3;
|
||||
assert_eq!(sampled_hex(&EyedropperTool::default().pointer(&cx, &click)), "#555555", "3 x 3 average");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_sampled_along_vector() {
|
||||
let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
|
||||
|
|
@ -150,7 +236,7 @@ mod tests {
|
|||
let shift = Mods { shift: true, ..Default::default() };
|
||||
// 80 % along the baseline (text space (80, -5)).
|
||||
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]);
|
||||
assert_eq!(sampled_hex(&a), "#333333");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use vectorcraft_doc::NodeKind;
|
|||
use crate::panels::stroke as stroke_panel;
|
||||
use crate::state::{ZOOM_STOPS, zoom_label};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{self, paint_chip};
|
||||
use crate::widgets;
|
||||
use crate::{VectorcraftApp, icons, menus, titlebar};
|
||||
|
||||
/// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher
|
||||
|
|
@ -88,17 +88,6 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A Control bar chip that opens something: `draw` paints the chip, a chevron follows it.
|
||||
fn chip_button(ui: &mut Ui, tip: &str, draw: impl FnOnce(&Ui, egui::Rect)) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::click());
|
||||
let chip = egui::Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(18.0, 18.0));
|
||||
draw(ui, chip);
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
|
||||
icons::paint(ui, "chevron-down", egui::Rect::from_min_size(r.min + vec2(21.0, 5.0), vec2(12.0, 12.0)), t.text_dim);
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
/// The Control bar (Window → Control), context-sensitive like Illustrator's.
|
||||
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
@ -127,24 +116,12 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
};
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
let (fill, stroke) = crate::panels::current_paints(app);
|
||||
let shown_stroke = crate::panels::current_stroke(app);
|
||||
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
|
||||
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
|
||||
let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 };
|
||||
if chip_button(ui, "Fill", |ui, r| paint_chip(ui, r, &fill)).clicked() {
|
||||
app.run("paint.toggleActive", json!({})).ok();
|
||||
app.session.fill_active = true;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
let stroke_chip = |ui: &Ui, r: egui::Rect| {
|
||||
paint_chip(ui, r, &stroke);
|
||||
ui.painter().rect_filled(r.shrink(5.0), 0.0, t.panel);
|
||||
};
|
||||
if chip_button(ui, "Stroke", stroke_chip).clicked() {
|
||||
app.session.fill_active = false;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
crate::panels::paint_chip(app, ui, false, 22.0, true);
|
||||
crate::panels::paint_chip(app, ui, true, 22.0, true);
|
||||
// The link opens the Stroke panel as a popover under it; then the weight spinner
|
||||
// (with presets) and the width profile.
|
||||
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
|
||||
|
|
@ -170,7 +147,8 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if ui.link(egui::RichText::new("Style:").size(12.0).color(t.text).underline()).clicked() {
|
||||
app.ui.open_panel = Some("graphicStyles".into());
|
||||
}
|
||||
let resp = chip_button(ui, "Graphic Style", |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r));
|
||||
let resp = widgets::chip_button(ui, 22.0, true, |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r))
|
||||
.on_hover_text("Graphic Style");
|
||||
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
|
||||
}
|
||||
ui.separator();
|
||||
|
|
@ -455,7 +433,14 @@ fn hint_for(tool: &str) -> &'static [(&'static str, bool)] {
|
|||
("Drag", true),
|
||||
(" to zoom into an area", false),
|
||||
],
|
||||
"eyedropper" => &[("Click", true), (" an object to copy its appearance | ", false), ("Shift+Click", true), (" to sample a color", false)],
|
||||
"eyedropper" => &[
|
||||
("Click", true),
|
||||
(" an object to copy its attributes | ", false),
|
||||
("Alt+Click", true),
|
||||
(" to apply the selection's to it | ", false),
|
||||
("Shift+Click", true),
|
||||
(" to sample a color", false),
|
||||
],
|
||||
"gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)],
|
||||
"artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)],
|
||||
_ => &[("Press ", false), ("Cmd+Shift+/", true), (" to search every command", false)],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
//! Eyedropper Options (double-click the Eyedropper tool): the raster sample size and the Picks Up /
|
||||
//! Applies trees of attributes. OK runs `eyedropper.setOptions` (the options live in the
|
||||
//! preferences).
|
||||
//!
|
||||
//! Fields: `sampleSize` (1, 3 or 5), `pickUp` and `apply` (`{appearance: {transparency, fill:
|
||||
//! {color, transparency, overprint}, stroke: {color, transparency, overprint, weight, cap, join,
|
||||
//! miter, dash}}, character, paragraph}`, each leaf a bool).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Eyedropper Options.
|
||||
pub const KIND: &str = "eyedropperOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Eyedropper Options".into(), body, confirm, min_width: 460.0, ..DialogSpec::FORM };
|
||||
|
||||
/// The raster sample sizes: (pixels square, label).
|
||||
const SAMPLE_SIZES: [(u64, &str); 3] = [(1, "Point Sample"), (3, "3 x 3 Average"), (5, "5 x 5 Average")];
|
||||
|
||||
/// The rows of a Picks Up / Applies tree: (JSON pointer into the tree, label, depth).
|
||||
const TREE: &[(&str, &str, u8)] = &[
|
||||
("/appearance", "Appearance", 0),
|
||||
("/appearance/transparency", "Transparency", 1),
|
||||
("/appearance/fill", "Focal Fill", 1),
|
||||
("/appearance/fill/color", "Color", 2),
|
||||
("/appearance/fill/transparency", "Transparency", 2),
|
||||
("/appearance/fill/overprint", "Overprint", 2),
|
||||
("/appearance/stroke", "Focal Stroke", 1),
|
||||
("/appearance/stroke/color", "Color", 2),
|
||||
("/appearance/stroke/transparency", "Transparency", 2),
|
||||
("/appearance/stroke/overprint", "Overprint", 2),
|
||||
("/appearance/stroke/weight", "Weight", 2),
|
||||
("/appearance/stroke/cap", "Cap", 2),
|
||||
("/appearance/stroke/join", "Join", 2),
|
||||
("/appearance/stroke/miter", "Miter Limit", 2),
|
||||
("/appearance/stroke/dash", "Dash Pattern", 2),
|
||||
("/character", "Character Style", 0),
|
||||
("/paragraph", "Paragraph Style", 0),
|
||||
];
|
||||
|
||||
/// Open the dialog with the current options.
|
||||
pub fn open(app: &mut VectorcraftApp) {
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!(app.session.prefs.eyedropper)));
|
||||
}
|
||||
|
||||
/// A flag or a branch of flags: `Some(on)` when every flag under it is `on`, `None` when mixed.
|
||||
fn state(v: &Value) -> Option<bool> {
|
||||
match v {
|
||||
Value::Bool(b) => Some(*b),
|
||||
Value::Object(o) => {
|
||||
let mut s = o.values().map(state);
|
||||
let first = s.next()??;
|
||||
s.all(|x| x == Some(first)).then_some(first)
|
||||
}
|
||||
_ => Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set every flag under `v` to `on`.
|
||||
fn set_all(v: &mut Value, on: bool) {
|
||||
match v {
|
||||
Value::Object(o) => o.values_mut().for_each(|c| set_all(c, on)),
|
||||
_ => *v = Value::Bool(on),
|
||||
}
|
||||
}
|
||||
|
||||
/// One tree (`key`: `pickUp` or `apply`) of checkboxes; a branch's box sets everything under it.
|
||||
fn tree(ui: &mut egui::Ui, d: &mut Dialog, key: &str, title: &str) {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(title).strong());
|
||||
ui.add_space(4.0);
|
||||
let Some(t) = d.fields.get_mut(key) else { return };
|
||||
for (path, label, depth) in TREE {
|
||||
let Some(node) = t.pointer_mut(path) else { continue };
|
||||
let s = state(node);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(*depth as f32 * 18.0);
|
||||
if widgets::check3(ui, label, s, true) {
|
||||
set_all(node, s != Some(true));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let size = d.fields.get("sampleSize").and_then(Value::as_u64).unwrap_or(1);
|
||||
let current = SAMPLE_SIZES.iter().find(|(n, _)| *n == size).map_or(SAMPLE_SIZES[0].1, |s| s.1);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, "Raster Sample Size:");
|
||||
let labels = SAMPLE_SIZES.map(|s| s.1);
|
||||
if let Some(i) = widgets::dropdown(ui, "eyedropper-sample", current, &labels, 150.0) {
|
||||
d.fields.insert("sampleSize".into(), json!(SAMPLE_SIZES[i].0));
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
tree(ui, d, "pickUp", "Eyedropper Picks Up:");
|
||||
ui.add_space(24.0);
|
||||
tree(ui, d, "apply", "Eyedropper Applies:");
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
super::run_and_close(app, "eyedropper.setOptions", Value::Object(d.fields.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn the_trees_set_the_options() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("tool.options", json!({"tool": "eyedropper"})).unwrap();
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx()));
|
||||
for label in [
|
||||
"Eyedropper Options",
|
||||
"Raster Sample Size:",
|
||||
"Point Sample",
|
||||
"Eyedropper Picks Up:",
|
||||
"Eyedropper Applies:",
|
||||
"Focal Stroke",
|
||||
"Dash Pattern",
|
||||
"Paragraph Style",
|
||||
] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
// A branch's box sets the flags under it; a mixed branch shows a dash.
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
let t = d.fields.get_mut("apply").unwrap();
|
||||
set_all(t.pointer_mut("/appearance/stroke").unwrap(), false);
|
||||
assert_eq!(state(&t["appearance"]["stroke"]), Some(false));
|
||||
assert_eq!(state(&t["appearance"]), None);
|
||||
d.fields.insert("sampleSize".into(), json!(5));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let o = app.session.prefs.eyedropper;
|
||||
assert_eq!(
|
||||
(o.sample_size, o.apply.appearance.stroke.dash, o.apply.appearance.fill.color, o.pick_up.appearance.stroke.dash),
|
||||
(5, false, true, true)
|
||||
);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ pub mod confirm;
|
|||
mod document_setup;
|
||||
mod effect;
|
||||
mod export_for_screens;
|
||||
pub mod eyedropper;
|
||||
mod form;
|
||||
mod gradient_stop;
|
||||
pub mod graphic_style_options;
|
||||
|
|
@ -154,6 +155,7 @@ registry! {
|
|||
Saturate: [saturate::KIND] => saturate::SPEC,
|
||||
SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC,
|
||||
TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC,
|
||||
EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ pub mod workspaces;
|
|||
mod tests_labels;
|
||||
#[cfg(test)]
|
||||
mod tests_overprint;
|
||||
#[cfg(test)]
|
||||
mod tests_paintchips;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"tool.options",
|
||||
"Tool Options…",
|
||||
"",
|
||||
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `command` running eyedropper.setOptions, fields appearance, transparency)",
|
||||
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions)",
|
||||
),
|
||||
(
|
||||
"ui.colorGuideOptions",
|
||||
|
|
@ -1456,7 +1456,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
|
|||
continue;
|
||||
}
|
||||
let label = dynamic_label(app, id, label);
|
||||
let sc = if p.is_null() { shortcut_of(id).map(pretty_shortcut).unwrap_or_default() } else { String::new() };
|
||||
let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default();
|
||||
let chk = checked(app, id, p);
|
||||
let text = match chk {
|
||||
Some(true) => format!("✓ {label}"),
|
||||
|
|
@ -1638,7 +1638,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
|
|||
command: Some(id.to_string()),
|
||||
params: p.clone(),
|
||||
enabled: enabled(app, id),
|
||||
shortcut: shortcut_of(id).unwrap_or("").to_string(),
|
||||
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
|
||||
}),
|
||||
Item::Todo(l, sc) => out.push(MenuEntry {
|
||||
path: path.clone(),
|
||||
|
|
@ -1787,6 +1787,16 @@ fn effect_menu() -> Vec<Item> {
|
|||
out
|
||||
}
|
||||
|
||||
/// The shortcut a menu item shows: its command's (items without params), or a panel's for the
|
||||
/// Window menu's `window.panel {panel}` items.
|
||||
pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> {
|
||||
match (id, p.get("panel").and_then(Value::as_str)) {
|
||||
("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel),
|
||||
_ if p.is_null() => shortcut_of(id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -192,14 +192,14 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) {
|
|||
}
|
||||
|
||||
/// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per
|
||||
/// document revision.
|
||||
/// document revision and Appearance panel item.
|
||||
pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) {
|
||||
let Some(st) = app.session.active() else { return (false, false) };
|
||||
let key = (st.uid, st.revision);
|
||||
match pstate::<Option<((u64, u64), (bool, bool))>>(ctx, "proxy-mixed") {
|
||||
let key = (st.uid, st.revision, app.session.appearance_item());
|
||||
match pstate::<Option<((u64, u64, Option<usize>), (bool, bool))>>(ctx, "proxy-mixed") {
|
||||
Some((k, mixed)) if k == key => mixed,
|
||||
_ => {
|
||||
let mixed = st.proxy_mixed();
|
||||
let mixed = app.session.proxy_mixed();
|
||||
set_pstate(ctx, "proxy-mixed", Some((key, mixed)));
|
||||
mixed
|
||||
}
|
||||
|
|
@ -272,6 +272,56 @@ pub(crate) fn proxy(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Fill (or `stroke`) chip of the Control bar and the Properties panel, `size` square (with a
|
||||
/// `chevron` after it): the proxy's paint, "?" when the selected objects' paints differ. A click
|
||||
/// brings that proxy forward and opens a popover with the Swatches panel; Shift-click (or the
|
||||
/// popover's toggle) shows the Color panel's mixer instead.
|
||||
pub(crate) fn paint_chip(app: &mut VectorcraftApp, ui: &mut Ui, stroke: bool, size: f32, chevron: bool) {
|
||||
let (fill, stroke_paint) = current_paints(app);
|
||||
let mixed = mixed_paints(app, ui.ctx());
|
||||
let (paint, mixed) = if stroke { (stroke_paint, mixed.1) } else { (fill, mixed.0) };
|
||||
let resp = crate::widgets::chip_button(ui, size, chevron, |ui, chip| {
|
||||
crate::widgets::proxy_chip(ui, chip, &paint, mixed, stroke.then_some(size * 0.25));
|
||||
});
|
||||
let resp = resp.on_hover_text(if stroke {
|
||||
"Stroke: click for swatches, Shift-click for the color mixer"
|
||||
} else {
|
||||
"Fill: click for swatches, Shift-click for the color mixer"
|
||||
});
|
||||
if resp.clicked() {
|
||||
app.run("paint.toggleActive", json!({ "fill": !stroke })).ok();
|
||||
set_pstate(ui.ctx(), MIXER, ui.input(|i| i.modifiers.shift));
|
||||
}
|
||||
egui::Popup::from_toggle_button_response(&resp)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.width(POPOVER_WIDTH)
|
||||
.show(|ui| paint_popover(app, ui));
|
||||
}
|
||||
|
||||
/// The chip popovers' width (a narrow Swatches or Color panel).
|
||||
const POPOVER_WIDTH: f32 = 248.0;
|
||||
/// Panel state: the chip popovers show the Color panel's mixer instead of the swatches.
|
||||
const MIXER: &str = "paint-popover-mixer";
|
||||
|
||||
/// The body of a Fill/Stroke chip popover: a Swatches / Color mixer toggle above that panel's body.
|
||||
fn paint_popover(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.set_width(POPOVER_WIDTH);
|
||||
let mixer: bool = pstate(ui.ctx(), MIXER);
|
||||
ui.horizontal(|ui| {
|
||||
for (icon, tip, m) in [("swatch-book", "Swatches", false), ("palette", "Color Mixer (Shift-click the chip)", true)] {
|
||||
if crate::widgets::icon_button(ui, icon, tip, mixer == m, 22.0).clicked() {
|
||||
set_pstate(ui.ctx(), MIXER, m);
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::divider(ui);
|
||||
if mixer {
|
||||
color::show(app, ui);
|
||||
} else {
|
||||
swatches::popover(app, ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `cmd` live: while dragging, preview on top of an interaction snapshot; on release, commit
|
||||
/// it as one undo step. Falls back to a plain run when no document is open.
|
||||
pub(crate) fn live_run(app: &mut VectorcraftApp, label: &str, cmd: &str, params: Value, phase: Live) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
//! The Properties panel: context-sensitive sections like Illustrator's.
|
||||
|
||||
use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeKind, Unit};
|
||||
|
||||
use super::first_selected;
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, dim_label, divider, section_header};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
|
@ -90,6 +90,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if is_live {
|
||||
actions.push(("Expand Shape", "object.expandShape"));
|
||||
}
|
||||
if multi_color(app, ui.ctx()) {
|
||||
actions.push(("Recolor", "ui.recolorDialog"));
|
||||
}
|
||||
actions.push(("Offset Path", "object.path.offsetPath"));
|
||||
actions.push(("Simplify", "object.path.simplify"));
|
||||
actions.push(("Arrange: Bring to Front", "object.arrange.bringToFront"));
|
||||
|
|
@ -106,6 +109,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the selection uses more than one colour (`recolor.colors`), which offers the Recolor
|
||||
/// quick action; cached per document revision.
|
||||
fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let Some(st) = app.session.active() else { return false };
|
||||
let key = (st.uid, st.revision);
|
||||
match pstate::<Option<((u64, u64), bool)>>(ctx, "props-multi-color") {
|
||||
Some((k, multi)) if k == key => multi,
|
||||
_ => {
|
||||
let r = app.session.execute("recolor.colors", &json!({})).unwrap_or_default();
|
||||
let multi = r["colors"].as_array().is_some_and(|c| c.len() > 1);
|
||||
set_pstate(ctx, "props-multi-color", Some((key, multi)));
|
||||
multi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let units = app.session.active().map(|d| d.doc.units).unwrap_or_default();
|
||||
section_header(ui, "Document");
|
||||
|
|
@ -262,25 +281,12 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
|
||||
fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(n) = first_selected(app) else { return };
|
||||
section_header(ui, "Appearance");
|
||||
let (fill, stroke) = super::current_paints(app);
|
||||
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
|
||||
for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] {
|
||||
for (label, is_fill) in [("Fill", true), ("Stroke", false)] {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
widgets::paint_chip(ui, r.shrink(2.0), &paint);
|
||||
if !is_fill {
|
||||
ui.painter().rect_filled(r.shrink(7.0), 0.0, t.panel);
|
||||
}
|
||||
ui.painter().rect_stroke(r.shrink(2.0), 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
|
||||
if resp.double_clicked() {
|
||||
app.run("ui.colorPicker", json!({ "stroke": !is_fill })).ok();
|
||||
} else if resp.clicked() {
|
||||
app.session.fill_active = is_fill;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
super::paint_chip(app, ui, !is_fill, 22.0, false);
|
||||
ui.label(egui::RichText::new(label).size(12.0));
|
||||
if !is_fill {
|
||||
ui.add_space(8.0);
|
||||
|
|
|
|||
|
|
@ -578,6 +578,21 @@ fn find_id() -> egui::Id {
|
|||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
body(app, ui, TILE);
|
||||
}
|
||||
|
||||
/// The panel in a Fill/Stroke chip popover (the Control bar's and Properties'): the same body, its
|
||||
/// tiles under their own ids so it can be open beside the panel.
|
||||
pub(crate) fn popover(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
body(app, ui, POPOVER_TILE);
|
||||
}
|
||||
|
||||
/// Id salts of the panel's tiles and of a popover's.
|
||||
const TILE: &str = "swatch-tile";
|
||||
const POPOVER_TILE: &str = "swatch-pop-tile";
|
||||
|
||||
/// The panel's body; `salt` keys its tiles (see [`tile_id`]).
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
|
||||
if app.session.active().is_none() {
|
||||
super::empty_state(ui, "swatch-book", "No document", "Open a document to see its swatches.");
|
||||
return;
|
||||
|
|
@ -621,7 +636,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if view.is_list() {
|
||||
for e in &items {
|
||||
let name = e.name();
|
||||
let (r, resp, chip) = list_row(ui, tile_id(e), view, is_sel(name));
|
||||
let (r, resp, chip) = list_row(ui, tile_id(salt, e), view, is_sel(name));
|
||||
match e {
|
||||
Entry::Registration => draw_registration(ui, chip),
|
||||
Entry::Swatch { paint, global, spot, .. } => {
|
||||
|
|
@ -636,7 +651,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
} else {
|
||||
for (e, cell) in tile_grid(ui, &items, view, Entry::is_folder) {
|
||||
let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag());
|
||||
let resp = ui.interact(cell, tile_id(salt, e), Sense::click_and_drag());
|
||||
let name = e.name();
|
||||
match e {
|
||||
Entry::Registration => draw_registration(ui, cell),
|
||||
|
|
@ -740,9 +755,9 @@ pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The interaction id of a tile or list row (stable per swatch or group name).
|
||||
fn tile_id(e: &Entry) -> egui::Id {
|
||||
egui::Id::new(("swatch-tile", e.name()))
|
||||
/// The interaction id of a tile or list row (stable per swatch or group name and host `salt`).
|
||||
fn tile_id(salt: &'static str, e: &Entry) -> egui::Id {
|
||||
egui::Id::new((salt, e.name()))
|
||||
}
|
||||
|
||||
/// The selected swatches and groups that can be deleted (all but None and Registration).
|
||||
|
|
|
|||
|
|
@ -79,11 +79,35 @@ pub fn default_command_shortcut(id: &str) -> Option<&'static str> {
|
|||
menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Default shortcut of an entry key (`tool:<id>` or a command id).
|
||||
/// Window menu items that show a panel (`window.panel {panel}`) with a default shortcut: (panel
|
||||
/// id, shortcut). Every icon panel can be given one in the editor (entry key `panel:<id>`).
|
||||
pub const PANEL_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("color", "F6"),
|
||||
("colorGuide", "Shift+F3"),
|
||||
("appearance", "Shift+F6"),
|
||||
("graphicStyles", "Shift+F5"),
|
||||
("stroke", "Cmd+F10"),
|
||||
("gradient", "Cmd+F9"),
|
||||
("transparency", "Cmd+Shift+F10"),
|
||||
];
|
||||
|
||||
/// Default shortcut of an entry key (`tool:<id>`, `panel:<id>` or a command id).
|
||||
pub fn default_of(key: &str) -> Option<&'static str> {
|
||||
match key.strip_prefix("tool:") {
|
||||
Some(t) => vectorcraft_tools::tool_info(t).and_then(|t| t.shortcut),
|
||||
None => default_command_shortcut(key),
|
||||
if let Some(t) = key.strip_prefix("tool:") {
|
||||
return vectorcraft_tools::tool_info(t).and_then(|t| t.shortcut);
|
||||
}
|
||||
if let Some(p) = key.strip_prefix("panel:") {
|
||||
return PANEL_SHORTCUTS.iter().find(|(id, _)| *id == p).map(|(_, sc)| *sc);
|
||||
}
|
||||
default_command_shortcut(key)
|
||||
}
|
||||
|
||||
/// Effective shortcut of an entry key, honouring the user's overrides.
|
||||
fn effective(key: &str) -> Option<&'static str> {
|
||||
match global_override(key) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
None => default_of(key),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,20 +122,17 @@ pub fn effective_in(overrides: &BTreeMap<String, String>, key: &str) -> Option<S
|
|||
|
||||
/// Effective shortcut of a command, honouring the user's overrides.
|
||||
pub fn command_shortcut(id: &str) -> Option<&'static str> {
|
||||
match global_override(id) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
None => default_command_shortcut(id),
|
||||
}
|
||||
effective(id)
|
||||
}
|
||||
|
||||
/// Effective shortcut of a tool.
|
||||
pub fn tool_shortcut(tool: &str) -> Option<&'static str> {
|
||||
match global_override(&format!("tool:{tool}")) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
None => vectorcraft_tools::tool_info(tool).and_then(|t| t.shortcut),
|
||||
}
|
||||
effective(&format!("tool:{tool}"))
|
||||
}
|
||||
|
||||
/// Effective shortcut of the Window menu item showing `panel` (`window.panel {panel}`).
|
||||
pub fn panel_shortcut(panel: &str) -> Option<&'static str> {
|
||||
effective(&format!("panel:{panel}"))
|
||||
}
|
||||
|
||||
/// Tool whose (effective) shortcut is `key` ("V", "Shift+M").
|
||||
|
|
@ -205,6 +226,12 @@ pub fn entries() -> &'static [Entry] {
|
|||
}
|
||||
v.push(Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false });
|
||||
}
|
||||
v.extend(crate::state::ICON_PANELS.iter().map(|(id, label, _)| Entry {
|
||||
key: format!("panel:{id}"),
|
||||
label: label.to_string(),
|
||||
group: "Window".into(),
|
||||
is_tool: false,
|
||||
}));
|
||||
for (id, label, sc, params) in menus::UI_COMMANDS {
|
||||
if !seen.insert(id) || (sc.is_empty() && !params.starts_with("{}") && !params.starts_with("{path")) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -43,21 +43,27 @@ pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
|||
Some(KeyboardShortcut::new(m, key))
|
||||
}
|
||||
|
||||
/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win).
|
||||
fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
|
||||
let mut v: Vec<(KeyboardShortcut, &'static str)> = vec![];
|
||||
/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win), with the
|
||||
/// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`).
|
||||
pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> {
|
||||
let mut v = vec![];
|
||||
for c in vectorcraft_engine::command_specs() {
|
||||
if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) {
|
||||
v.push((sc, c.id));
|
||||
v.push((sc, c.id, json!({})));
|
||||
}
|
||||
}
|
||||
for c in crate::menus::UI_COMMANDS {
|
||||
if let Some(sc) = crate::menus::shortcut_of(c.0).and_then(parse) {
|
||||
v.push((sc, c.0));
|
||||
v.push((sc, c.0, json!({})));
|
||||
}
|
||||
}
|
||||
for (panel, _, _) in crate::state::ICON_PANELS {
|
||||
if let Some(sc) = crate::shortcut_editor::panel_shortcut(panel).and_then(parse) {
|
||||
v.push((sc, "window.panel", json!({ "panel": panel })));
|
||||
}
|
||||
}
|
||||
// Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z.
|
||||
v.sort_by_key(|(sc, _)| {
|
||||
v.sort_by_key(|(sc, ..)| {
|
||||
std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8)
|
||||
});
|
||||
v
|
||||
|
|
@ -105,13 +111,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
// Editing keys with modifiers, clipboard and Cmd+A.
|
||||
crate::panels::character::route_type_input(app, ctx);
|
||||
// Enter was already delivered above as ToolKey::Enter (newline).
|
||||
let fire = all_shortcuts()
|
||||
.into_iter()
|
||||
.filter(|(sc, _)| sc.modifiers.command)
|
||||
.find(|(sc, _)| ctx.input_mut(|i| i.consume_shortcut(sc)))
|
||||
.map(|(_, id)| id);
|
||||
if let Some(id) = fire {
|
||||
crate::menus::invoke(app, id, json!({}));
|
||||
let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| i.consume_shortcut(sc)));
|
||||
if let Some((_, id, p)) = fire {
|
||||
crate::menus::invoke(app, id, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -156,8 +158,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
// Command shortcuts.
|
||||
let mut fire: Option<&'static str> = None;
|
||||
for (sc, id) in all_shortcuts() {
|
||||
let mut fire = None;
|
||||
for (sc, id, p) in all_shortcuts() {
|
||||
// Letter / punctuation keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts,
|
||||
// X, D, /, `,` and `.`).
|
||||
let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl);
|
||||
|
|
@ -168,12 +170,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
continue;
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&sc)) {
|
||||
fire = Some(id);
|
||||
fire = Some((id, p));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(id) = fire {
|
||||
crate::menus::invoke(app, id, json!({}));
|
||||
if let Some((id, p)) = fire {
|
||||
crate::menus::invoke(app, id, p);
|
||||
return;
|
||||
}
|
||||
// Arrow nudges (Shift = ×10, Alt = copy).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
//! The Fill/Stroke proxies and chips (toolbar, Control bar, Properties), their swatch and mixer
|
||||
//! popovers, the Recolor quick action and the panel shortcuts (M3.51).
|
||||
|
||||
use egui::{Color32, Event, Modifiers, PointerButton, Pos2, Rect, Shape, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{VectorcraftApp, chrome, panels, shortcut_editor, shortcuts, toolbar};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn context() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx
|
||||
}
|
||||
|
||||
/// One headless frame of `draw` with `events` (and `modifiers` held); returns the painted shapes.
|
||||
fn frame(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &egui::Context,
|
||||
events: Vec<Event>,
|
||||
modifiers: Modifiers,
|
||||
draw: fn(&mut VectorcraftApp, &mut egui::Ui),
|
||||
) -> Vec<Shape> {
|
||||
let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 1000.0)));
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| draw(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.into_iter().map(|c| c.shape).collect()
|
||||
}
|
||||
|
||||
/// Click at `at` with `modifiers` held (press and release in two frames), then settle a frame.
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2, modifiers: Modifiers, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers };
|
||||
frame(app, ctx, vec![Event::PointerMoved(at), button(true)], modifiers, draw);
|
||||
frame(app, ctx, vec![button(false)], modifiers, draw);
|
||||
frame(app, ctx, vec![], Modifiers::NONE, draw);
|
||||
}
|
||||
|
||||
/// Whether `shapes` paint a rectangle filled with `color`.
|
||||
fn paints_rect(shapes: &[Shape], color: Color32) -> bool {
|
||||
shapes.iter().any(|s| match s {
|
||||
Shape::Rect(r) => r.fill == color,
|
||||
Shape::Vec(v) => paints_rect(v, color),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The Control bar's Fill and Stroke chips (22 pt squares with a chevron), left to right.
|
||||
fn chips(ctx: &egui::Context) -> (Rect, Rect) {
|
||||
let mut r: Vec<Rect> = ctx.viewport(|vp| {
|
||||
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(38.0, 22.0)).map(|w| w.rect).collect()
|
||||
});
|
||||
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
(r[0], r[1])
|
||||
}
|
||||
|
||||
fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
chrome::control_bar(app, ui);
|
||||
}
|
||||
|
||||
fn fill_hex(app: &VectorcraftApp) -> String {
|
||||
panels::current_paints(app).0.color().map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn red_text_shows_a_red_toolbar_fill() {
|
||||
let mut app = app();
|
||||
app.run("text.create", json!({"x": 20, "y": 60, "text": "Red"})).unwrap();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], Modifiers::NONE, toolbar::show);
|
||||
let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, toolbar::show);
|
||||
assert!(paints_rect(&shapes, Color32::from_rgb(255, 0, 0)), "the toolbar's Fill proxy shows the type's red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chips_show_a_question_mark_for_mixed_fills() {
|
||||
let mut app = app();
|
||||
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||||
let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"color": "#00ff00"})).unwrap();
|
||||
let text = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, control_bar);
|
||||
assert!(!text(&mut app).lines().any(|l| l == "?"));
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
assert!(text(&mut app).lines().any(|l| l == "?"), "the Fill chip shows ?");
|
||||
let props = crate::tests_labels::painted_text(&mut app, panels::properties::show);
|
||||
assert!(props.lines().any(|l| l == "?"), "so does the Properties panel's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_popover_tile_click_sets_the_fill() {
|
||||
let mut app = app();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("paint.toggleActive", json!({"fill": false})).unwrap();
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar);
|
||||
let (fill, stroke) = chips(&ctx);
|
||||
click(&mut app, &ctx, fill.center(), Modifiers::NONE, control_bar);
|
||||
assert!(app.session.fill_active, "the Fill chip brings its proxy forward");
|
||||
let tile = ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).expect("the popover shows the swatches").rect;
|
||||
click(&mut app, &ctx, tile.center(), Modifiers::NONE, control_bar);
|
||||
assert_eq!(fill_hex(&app), "#ed1c24");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Fill Color", "through paint.setFill");
|
||||
// The Stroke chip: its proxy comes forward; Shift-click shows the mixer instead of swatches.
|
||||
click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, control_bar);
|
||||
click(&mut app, &ctx, stroke.center(), Modifiers::SHIFT, control_bar);
|
||||
assert!(!app.session.fill_active);
|
||||
assert!(ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).is_none(), "no swatches in the mixer");
|
||||
let text = {
|
||||
let mut t = String::new();
|
||||
for s in frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar) {
|
||||
if let Shape::Text(g) = s {
|
||||
t.push_str(g.galley.text());
|
||||
t.push('\n');
|
||||
}
|
||||
}
|
||||
t
|
||||
};
|
||||
assert!(text.contains("Recent Colors"), "the Color panel's body: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recolor_is_a_quick_action_for_multicolour_selections() {
|
||||
let mut app = app();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("paint.setStroke", json!({"none": true})).unwrap();
|
||||
let text = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, panels::properties::show);
|
||||
assert!(!text(&mut app).contains("Recolor"), "one colour");
|
||||
app.run("paint.setStroke", json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(text(&mut app).contains("Recolor"), "white and red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortcut_table_maps_f6_to_color() {
|
||||
assert_eq!(shortcut_editor::panel_shortcut("color"), Some("F6"));
|
||||
assert_eq!(shortcut_editor::panel_shortcut("transparency"), Some("Cmd+Shift+F10"));
|
||||
let all = shortcuts::all_shortcuts();
|
||||
let f6 = all.iter().find(|(sc, ..)| sc.logical_key == egui::Key::F6 && sc.modifiers == Modifiers::NONE).expect("F6");
|
||||
assert_eq!((f6.1, &f6.2), ("window.panel", &json!({"panel": "color"})));
|
||||
// The keys toggle their panels; Shift+F6 isn't taken by F6.
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
let key =
|
||||
|key, modifiers| vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }];
|
||||
let press = |app: &mut VectorcraftApp, k, m| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events: key(k, m), ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
press(&mut app, egui::Key::F6, Modifiers::NONE);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("color"));
|
||||
press(&mut app, egui::Key::F6, Modifiers::SHIFT);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("appearance"));
|
||||
press(&mut app, egui::Key::F9, Modifiers::COMMAND);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
|
||||
// The Window menu lists them.
|
||||
let color = crate::menus::menu_entries(&app).into_iter().find(|e| e.path == ["Window"] && e.label == "Color").unwrap();
|
||||
assert_eq!(color.shortcut, "F6");
|
||||
}
|
||||
|
|
@ -188,15 +188,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
|
|||
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
|
||||
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
|
||||
"eyedropper" => {
|
||||
let o = app.session.eyedropper;
|
||||
let fields = json!({
|
||||
"__command": "eyedropper.setOptions",
|
||||
"__label": "Eyedropper Options",
|
||||
"appearance": o.appearance,
|
||||
"transparency": o.transparency,
|
||||
});
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("command", fields));
|
||||
Ok(json!({ "dialog": "command" }))
|
||||
crate::dialogs::eyedropper::open(app);
|
||||
Ok(json!({ "dialog": crate::dialogs::eyedropper::KIND }))
|
||||
}
|
||||
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
|
||||
_ => Err(format!("the {tool} tool has no options")),
|
||||
|
|
@ -362,11 +355,12 @@ mod tests {
|
|||
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
|
||||
double_click(&mut app, &ctx, 3.0, eyedropper.center());
|
||||
let d = app.ui.dialog.clone().expect("Eyedropper Options");
|
||||
assert_eq!((app.session.tool_id(), d.kind.as_str(), d.str("__command")), ("eyedropper", "command", "eyedropper.setOptions".into()));
|
||||
assert_eq!((app.session.tool_id(), d.kind.as_str()), ("eyedropper", crate::dialogs::eyedropper::KIND));
|
||||
// OK applies the options.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("transparency".into(), json!(false));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("apply".into(), json!({"appearance": {"transparency": false}}));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.session.eyedropper.appearance && !app.session.eyedropper.transparency);
|
||||
let o = app.session.prefs.eyedropper;
|
||||
assert!(o.pick_up.appearance.transparency && !o.apply.appearance.transparency && o.apply.appearance.fill.color);
|
||||
assert!(app.run("tool.options", json!({"tool": "zoom"})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,6 +236,45 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient
|
|||
}
|
||||
}
|
||||
|
||||
/// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a
|
||||
/// panel-coloured hole (returned; the whole chip for fills). A selection whose paints differ
|
||||
/// (`mixed`) shows a white chip with a "?" (in the hole for strokes).
|
||||
pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option<f32>) -> Rect {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if mixed {
|
||||
ui.painter().rect_filled(rect, 0.0, Color32::WHITE);
|
||||
} else {
|
||||
paint_chip(ui, rect, paint);
|
||||
}
|
||||
let (hole, mark) = match ring {
|
||||
Some(w) => {
|
||||
let inner = rect.shrink(w);
|
||||
ui.painter().rect_filled(inner, 0.0, t.panel);
|
||||
(inner, t.text_strong)
|
||||
}
|
||||
None => (rect, Color32::BLACK),
|
||||
};
|
||||
if mixed {
|
||||
ui.painter().text(hole.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(hole.height() * 0.8), mark);
|
||||
}
|
||||
hole
|
||||
}
|
||||
|
||||
/// A chip that opens something (the Control bar's and Properties' Fill/Stroke chips, the Control
|
||||
/// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with
|
||||
/// `chevron` a chevron follows it.
|
||||
pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click());
|
||||
let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0);
|
||||
draw(ui, chip);
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
|
||||
if chevron {
|
||||
icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// What the user did on a [`fill_stroke_proxy`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProxyClicks {
|
||||
|
|
@ -263,31 +302,13 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool
|
|||
drag_source(ui, resp, || PanelDrag::paint(paint.clone()));
|
||||
}
|
||||
}
|
||||
let question = |ui: &Ui, r: Rect, color: Color32| {
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color);
|
||||
};
|
||||
let draw_fill = |ui: &Ui| {
|
||||
if mixed.0 {
|
||||
ui.painter().rect_filled(fill_r, 0.0, Color32::WHITE);
|
||||
question(ui, fill_r, Color32::BLACK);
|
||||
} else {
|
||||
paint_chip(ui, fill_r, fill);
|
||||
}
|
||||
proxy_chip(ui, fill_r, fill, mixed.0, None);
|
||||
ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
|
||||
};
|
||||
let draw_stroke = |ui: &Ui| {
|
||||
let w = s * 0.28;
|
||||
if mixed.1 {
|
||||
ui.painter().rect_filled(stroke_r, 0.0, Color32::WHITE);
|
||||
} else {
|
||||
paint_chip(ui, stroke_r, stroke);
|
||||
}
|
||||
let inner = stroke_r.shrink(w);
|
||||
ui.painter().rect_filled(inner, 0.0, t.panel);
|
||||
if mixed.1 {
|
||||
question(ui, inner, t.text_strong);
|
||||
}
|
||||
let inner = proxy_chip(ui, stroke_r, stroke, mixed.1, Some(s * 0.28));
|
||||
ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
|
||||
|
|
|
|||
|
|
@ -53,8 +53,9 @@ active proxy) with the paint's params and the object's `ids`; a colour dropped o
|
|||
a stop.
|
||||
|
||||
Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gradient` it opens the Gradient panel;
|
||||
for `eyedropper` it opens Eyedropper Options, a `command` dialog (fields `appearance`, `transparency`) whose
|
||||
`ui.dialog.confirm` runs `eyedropper.setOptions` (what `appearance.copyFrom` copies). Gradient tool handles snap to
|
||||
for `eyedropper` it opens Eyedropper Options, an `eyedropperOptions` dialog (fields `sampleSize` 1/3/5, `pickUp` and
|
||||
`apply`, the attribute trees of `eyedropper.setOptions`) whose `ui.dialog.confirm` runs `eyedropper.setOptions` (what
|
||||
`appearance.copyFrom` copies). Gradient tool handles snap to
|
||||
anchors, edges and smart guides; Shift constrains them to 45° steps from the `constrainAngle` preference.
|
||||
|
||||
Effect dialogs: `engine.execute {command: "effect.dialog", params: {effect, index?, item?}}` opens the `effect` dialog
|
||||
|
|
@ -100,3 +101,9 @@ caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end,
|
|||
not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for
|
||||
modified documents), and empty bar space and the 5 pt window edges move and resize the window. macOS and the web
|
||||
build are unchanged.
|
||||
|
||||
Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy
|
||||
forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer);
|
||||
a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Color F6, Color Guide Shift+F3,
|
||||
Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run
|
||||
`window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items.
|
||||
|
|
|
|||
14
docs/mcp.md
14
docs/mcp.md
|
|
@ -324,6 +324,7 @@ filter on the whole group.
|
|||
{"name":"run_command","arguments":{"command":"appearance.moveItem","params":{"ids":[7],"from":"contents","to":1}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.apply","params":{"ids":[2],"effect":"stylize.dropShadow"}}}
|
||||
{"name":"run_command","arguments":{"command":"appearance.addStroke","params":{"ids":[7],"target":"contents"}}}
|
||||
```
|
||||
|
||||
## Swatch libraries
|
||||
|
||||
|
|
@ -382,3 +383,16 @@ In pattern editing mode the tile edge (`pattern.options {showTileEdge}`) and the
|
|||
{"name":"run_command","arguments":{"command":"pattern.options","params":{"showSwatchBounds":true}}}
|
||||
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}}
|
||||
```
|
||||
|
||||
The Eyedropper: `appearance.copyFrom {source}` copies what the Eyedropper Options pick up and apply from `source` to the
|
||||
selection (`ids`); `reverse: true` copies the selection's attributes onto `source` (Alt-click) and `append: true` adds
|
||||
the source's fills and strokes on top of each target's stack (Shift+Alt-click). The options live in the preferences:
|
||||
`eyedropper.setOptions {sampleSize?, pickUp?, apply?}` (or `prefs.get`/`prefs.set` with key `eyedropper`) reads and
|
||||
sets them; a tree names flags under `appearance` (`transparency`, `fill` `{color, transparency, overprint}`, `stroke`
|
||||
`{color, transparency, overprint, weight, cap, join, miter, dash}`), `character` and `paragraph`, and a bool for a
|
||||
branch sets all of it. `paint.sampleColor {color}` puts a sampled colour (in its own model) into the active proxy.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"eyedropper.setOptions","params":{"pickUp":{"appearance":{"stroke":{"weight":false}}}}}}
|
||||
{"name":"run_command","arguments":{"command":"appearance.copyFrom","params":{"source":12,"ids":[7,8]}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue