mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Merge main into fix/nl-message-rows
This commit is contained in:
@@ -31,7 +31,7 @@ pub(crate) fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// Windows: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
///
|
||||
/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
|
||||
|
||||
+61
-20
@@ -763,33 +763,19 @@ impl Node {
|
||||
children.iter().rposition(|c| !c.is_ruler_guide()).map_or(0, |i| i + 1)
|
||||
}
|
||||
|
||||
/// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none.
|
||||
/// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none. Only
|
||||
/// layers that hold one are copied.
|
||||
pub fn strip_ruler_guides(&mut self) {
|
||||
if !self.holds_ruler_guides() {
|
||||
return;
|
||||
}
|
||||
if let Some(ch) = self.children_mut() {
|
||||
ch.retain(|c| !c.is_ruler_guide());
|
||||
for c in ch.iter_mut().filter(|c| c.holds_ruler_guides()) {
|
||||
Arc::make_mut(c).strip_ruler_guides();
|
||||
}
|
||||
if let Some(held) = held_guides(self, &Self::is_ruler_guide) {
|
||||
strip_held(self, held);
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` on the position (along the axis it crosses) of each ruler guide of this layer and
|
||||
/// of its sublayers, with whether the guide is vertical. Only layers that hold one are copied.
|
||||
pub fn map_ruler_guide_positions(&mut self, f: &mut impl FnMut(bool, &mut f64)) {
|
||||
if !self.holds_ruler_guides() {
|
||||
return;
|
||||
}
|
||||
if let Some(ch) = self.children_mut() {
|
||||
for c in ch.iter_mut().filter(|c| c.is_ruler_guide() || c.holds_ruler_guides()) {
|
||||
let c = Arc::make_mut(c);
|
||||
match &mut c.kind {
|
||||
NodeKind::RulerGuide { vertical, pos, .. } => f(*vertical, pos),
|
||||
_ => c.map_ruler_guide_positions(f),
|
||||
}
|
||||
}
|
||||
if let Some(held) = held_guides(self, &Self::is_ruler_guide) {
|
||||
map_held(self, held, f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,6 +820,61 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ruler guides a layer holds: its children that are one, by index in order (each with
|
||||
/// nothing inside), and its sublayers holding one (with what they hold).
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct HeldGuides(Vec<(usize, HeldGuides)>);
|
||||
|
||||
/// The ruler guides, by `is_guide`, that `n` holds if it is a layer, or `None` when it holds
|
||||
/// none. `is_guide` looks at each child of `n` and of its sublayers once.
|
||||
pub(super) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option<HeldGuides> {
|
||||
if !n.is_layer() {
|
||||
return None;
|
||||
}
|
||||
let held: Vec<_> = n
|
||||
.children()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| if is_guide(c) { Some((i, HeldGuides::default())) } else { held_guides(c, is_guide).map(|h| (i, h)) })
|
||||
.collect();
|
||||
(!held.is_empty()).then_some(HeldGuides(held))
|
||||
}
|
||||
|
||||
/// Remove the ruler guides of `layer` and of its sublayers that [`held_guides`] found (`held`).
|
||||
/// Only the sublayers holding one are copied, and only those are visited.
|
||||
pub(super) fn strip_held(layer: &mut Node, held: HeldGuides) {
|
||||
let Some(ch) = layer.children_mut() else { return };
|
||||
let mut held = held.0.into_iter().peekable();
|
||||
let mut next = 0;
|
||||
ch.retain_mut(|c| {
|
||||
let at = next;
|
||||
next += 1;
|
||||
let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true };
|
||||
// A sublayer holds something; a ruler guide has nothing inside.
|
||||
if inner.0.is_empty() {
|
||||
return false;
|
||||
}
|
||||
strip_held(Arc::make_mut(c), inner);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Call `f` (whether it is vertical, its position) on each ruler guide of `layer` and of its
|
||||
/// sublayers that [`held_guides`] found (`held`), in the order they lie in. Only the sublayers
|
||||
/// holding one, and the guides, are copied, and only those sublayers are visited.
|
||||
pub(super) fn map_held(layer: &mut Node, held: HeldGuides, f: &mut impl FnMut(bool, &mut f64)) {
|
||||
let Some(ch) = layer.children_mut() else { return };
|
||||
for (i, inner) in held.0 {
|
||||
let Some(c) = ch.get_mut(i) else { continue };
|
||||
if !inner.0.is_empty() {
|
||||
map_held(Arc::make_mut(c), inner, f);
|
||||
} else if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind {
|
||||
f(*vertical, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The Appearance panel's row for what this object's own fills and strokes paint around:
|
||||
/// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters"
|
||||
|
||||
@@ -598,3 +598,207 @@ fn compound_stroke_path_is_its_members_in_order() {
|
||||
assert_eq!(bp.elements(), expected.elements());
|
||||
assert!(!bp.elements().is_empty());
|
||||
}
|
||||
|
||||
/// The sublayers in [`guide_layers`]'s chain, each inside the one before.
|
||||
const GUIDE_CHAIN: u64 = 30;
|
||||
|
||||
/// How many children the layers of [`guide_layers`] hold: the layer's 6, 1 in the sublayer
|
||||
/// without a guide, 2 at each of the two places the shared sublayer lies, the innermost
|
||||
/// sublayer's 2 and 3 in each sublayer around it.
|
||||
const GUIDE_LAYER_CHILDREN: usize = 6 + 1 + 2 * 2 + 2 + (GUIDE_CHAIN as usize - 1) * 3;
|
||||
|
||||
/// How many ruler guides the layers of [`guide_layers`] hold: one in each sublayer of the chain,
|
||||
/// one at each place the shared sublayer lies and the layer's own.
|
||||
const GUIDE_LAYER_GUIDES: usize = GUIDE_CHAIN as usize + 2 + 1;
|
||||
|
||||
/// A layer holding, in order: [`GUIDE_CHAIN`] sublayers, each inside the one before and holding an
|
||||
/// object and a ruler guide; a sublayer with an object only; a sublayer with an object and a
|
||||
/// guide; an object; that last sublayer again; and a guide. Guides differ in direction, artboard
|
||||
/// and lock. Returns the layer, the sublayer without a guide and the one that lies twice.
|
||||
fn guide_layers() -> (Node, Arc<Node>, Arc<Node>) {
|
||||
let rect = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()));
|
||||
let guide = |id: u64| {
|
||||
let kind = NodeKind::RulerGuide { vertical: id.is_multiple_of(2), pos: id as f64, artboard: id.is_multiple_of(3).then_some(7) };
|
||||
let mut g = Node::new(NodeId(id), kind);
|
||||
g.locked = id.is_multiple_of(5);
|
||||
Arc::new(g)
|
||||
};
|
||||
let sublayer = |id, children: Vec<Arc<Node>>| {
|
||||
let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1));
|
||||
if let Some(ch) = l.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
Arc::new(l)
|
||||
};
|
||||
let mut chain = sublayer(100, vec![rect(150), guide(200)]);
|
||||
for depth in 1..GUIDE_CHAIN {
|
||||
chain = sublayer(100 + depth, vec![rect(600 + depth), chain, guide(300 + depth)]);
|
||||
}
|
||||
let still = sublayer(500, vec![rect(501)]);
|
||||
let twice = sublayer(510, vec![rect(511), guide(512)]);
|
||||
let mut layer = Node::layer(NodeId(1), "Layer", LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = vec![chain, Arc::clone(&still), Arc::clone(&twice), rect(2), Arc::clone(&twice), guide(3)];
|
||||
}
|
||||
(layer, still, twice)
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` hold the same children, none of them copied.
|
||||
fn same_children(a: &Node, b: &Node) -> bool {
|
||||
let (a, b) = (a.children().unwrap(), b.children().unwrap());
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b))
|
||||
}
|
||||
|
||||
/// Stripping the ruler guides of a layer looks at each child of the layer and of its sublayers
|
||||
/// once, however deep the sublayers nest. The art and its order stay, a sublayer without a guide is
|
||||
/// not copied, and a copy of the layer held elsewhere keeps its guides.
|
||||
#[test]
|
||||
fn stripping_ruler_guides_looks_at_each_child_of_nested_layers_once() {
|
||||
use std::cell::Cell;
|
||||
let (whole, still, twice) = guide_layers();
|
||||
let ids = |n: &Node| {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| out.push(c.id));
|
||||
out
|
||||
};
|
||||
let mut art = vec![];
|
||||
whole.walk(&mut |c| {
|
||||
if !c.is_ruler_guide() {
|
||||
art.push(c.id);
|
||||
}
|
||||
});
|
||||
assert_eq!(ids(&whole).len(), art.len() + GUIDE_LAYER_GUIDES);
|
||||
|
||||
let mut n = whole.clone();
|
||||
let looked = Cell::new(0_usize);
|
||||
let pick = |c: &Node| {
|
||||
looked.set(looked.get() + 1);
|
||||
c.is_ruler_guide()
|
||||
};
|
||||
let held = super::ops::held_guides(&n, &pick).unwrap();
|
||||
assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "each child of the layers is looked at once");
|
||||
super::ops::strip_held(&mut n, held);
|
||||
assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "removing looks at nothing again");
|
||||
assert_eq!(ids(&n), art, "the art stays, in its order");
|
||||
let ch = n.children().unwrap();
|
||||
assert!(Arc::ptr_eq(&ch[1], &still), "a sublayer without a guide is not copied");
|
||||
assert!(Arc::ptr_eq(&ch[3], &whole.children().unwrap()[3]), "an object is not copied");
|
||||
assert!(!Arc::ptr_eq(&ch[2], &twice) && !Arc::ptr_eq(&ch[4], &twice) && !Arc::ptr_eq(&ch[2], &ch[4]));
|
||||
assert_eq!(
|
||||
(ch[2].children().map(Vec::len), ch[4].children().map(Vec::len)),
|
||||
(Some(1), Some(1)),
|
||||
"a sublayer lying twice is stripped at both places"
|
||||
);
|
||||
// The copies held elsewhere (an undo step's, say) keep their guides.
|
||||
assert_eq!(twice.children().map(Vec::len), Some(2));
|
||||
assert_eq!(ids(&whole).len(), art.len() + GUIDE_LAYER_GUIDES);
|
||||
|
||||
// The method strips the same guides.
|
||||
let mut same = whole.clone();
|
||||
same.strip_ruler_guides();
|
||||
assert_eq!(same, n);
|
||||
assert!(Arc::ptr_eq(&same.children().unwrap()[1], &still));
|
||||
// With no guide left there is nothing to remove, and nothing is copied.
|
||||
let before = n.clone();
|
||||
n.strip_ruler_guides();
|
||||
assert!(same_children(&n, &before));
|
||||
let mut empty = Node::layer(NodeId(900), "Empty", LayerColor::Preset(0));
|
||||
empty.strip_ruler_guides();
|
||||
assert_eq!(empty.children().map(Vec::len), Some(0));
|
||||
// Only a layer holds ruler guides: a group is left as it is, on its own and in a layer.
|
||||
let mut group = Node::group(NodeId(901), vec![Arc::clone(&twice)]);
|
||||
assert!(super::ops::held_guides(&group, &Node::is_ruler_guide).is_none());
|
||||
group.strip_ruler_guides();
|
||||
assert!(Arc::ptr_eq(&group.children().unwrap()[0], &twice));
|
||||
let group = Arc::new(group);
|
||||
let mut around = Node::layer(NodeId(902), "Around", LayerColor::Preset(0));
|
||||
around.children_mut().unwrap().push(Arc::clone(&group));
|
||||
around.strip_ruler_guides();
|
||||
assert!(Arc::ptr_eq(&around.children().unwrap()[0], &group));
|
||||
}
|
||||
|
||||
/// Changing the positions of the ruler guides of a layer looks at each child of the layer and of
|
||||
/// its sublayers once, however deep the sublayers nest. Each guide is changed once, in the order
|
||||
/// the guides lie in, and keeps everything else about it; the art is unchanged, a sublayer without
|
||||
/// a guide is not copied, and a copy of the layer held elsewhere keeps its positions.
|
||||
#[test]
|
||||
fn mapping_ruler_guide_positions_looks_at_each_child_of_nested_layers_once() {
|
||||
use std::cell::Cell;
|
||||
let (whole, still, twice) = guide_layers();
|
||||
let mut lines = vec![];
|
||||
whole.walk(&mut |c| {
|
||||
if let NodeKind::RulerGuide { vertical, pos, .. } = c.kind {
|
||||
lines.push((vertical, pos));
|
||||
}
|
||||
});
|
||||
assert_eq!(lines.len(), GUIDE_LAYER_GUIDES);
|
||||
assert!(lines.iter().any(|l| l.0) && lines.iter().any(|l| !l.0), "guides in both directions");
|
||||
|
||||
let mut n = whole.clone();
|
||||
let looked = Cell::new(0_usize);
|
||||
let pick = |c: &Node| {
|
||||
looked.set(looked.get() + 1);
|
||||
c.is_ruler_guide()
|
||||
};
|
||||
let held = super::ops::held_guides(&n, &pick).unwrap();
|
||||
assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "each child of the layers is looked at once");
|
||||
let mut seen = vec![];
|
||||
super::ops::map_held(&mut n, held, &mut |vertical, pos| {
|
||||
seen.push((vertical, *pos));
|
||||
*pos = *pos * 2.0 + 1.0;
|
||||
});
|
||||
assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "changing looks at nothing again");
|
||||
assert_eq!(seen, lines, "each guide once, in the order the guides lie in");
|
||||
|
||||
// The same nodes in the same order: a guide differs in its position alone, and the art not
|
||||
// at all.
|
||||
let (mut was, mut now) = (vec![], vec![]);
|
||||
whole.walk(&mut |c| was.push(c));
|
||||
n.walk(&mut |c| now.push(c));
|
||||
assert_eq!(was.len(), now.len());
|
||||
for (a, b) in was.into_iter().zip(now) {
|
||||
if a.is_layer() {
|
||||
assert_eq!((b.id, b.is_layer(), b.children().map(Vec::len)), (a.id, true, a.children().map(Vec::len)));
|
||||
continue;
|
||||
}
|
||||
let mut want = a.clone();
|
||||
if let NodeKind::RulerGuide { pos, .. } = &mut want.kind {
|
||||
*pos = *pos * 2.0 + 1.0;
|
||||
}
|
||||
assert_eq!(b, &want);
|
||||
}
|
||||
let ch = n.children().unwrap();
|
||||
assert!(Arc::ptr_eq(&ch[1], &still), "a sublayer without a guide is not copied");
|
||||
assert!(Arc::ptr_eq(&ch[3], &whole.children().unwrap()[3]), "an object is not copied");
|
||||
assert!(!Arc::ptr_eq(&ch[2], &twice) && !Arc::ptr_eq(&ch[4], &twice) && !Arc::ptr_eq(&ch[2], &ch[4]));
|
||||
// The copy held elsewhere keeps its position.
|
||||
assert!(matches!(twice.children().unwrap()[1].kind, NodeKind::RulerGuide { pos, .. } if pos == 512.0));
|
||||
|
||||
// The method changes the same guides, each once.
|
||||
let mut same = whole.clone();
|
||||
let mut calls = 0;
|
||||
same.map_ruler_guide_positions(&mut |_, pos| {
|
||||
calls += 1;
|
||||
*pos = *pos * 2.0 + 1.0;
|
||||
});
|
||||
assert_eq!((calls, &same), (GUIDE_LAYER_GUIDES, &n));
|
||||
assert!(Arc::ptr_eq(&same.children().unwrap()[1], &still));
|
||||
// A function that leaves the positions as they are is still called on each guide.
|
||||
let mut kept = whole.clone();
|
||||
let mut calls = 0;
|
||||
kept.map_ruler_guide_positions(&mut |_, _| calls += 1);
|
||||
assert_eq!((calls, &kept), (GUIDE_LAYER_GUIDES, &whole));
|
||||
// A layer without a guide, an empty layer and a group: nothing to change, nothing copied.
|
||||
let mut bare = whole.clone();
|
||||
bare.strip_ruler_guides();
|
||||
let before = bare.clone();
|
||||
let mut calls = 0;
|
||||
bare.map_ruler_guide_positions(&mut |_, _| calls += 1);
|
||||
assert!(same_children(&bare, &before));
|
||||
let mut empty = Node::layer(NodeId(900), "Empty", LayerColor::Preset(0));
|
||||
empty.map_ruler_guide_positions(&mut |_, _| calls += 1);
|
||||
let mut group = Node::group(NodeId(901), vec![Arc::clone(&twice)]);
|
||||
group.map_ruler_guide_positions(&mut |_, _| calls += 1);
|
||||
assert!(Arc::ptr_eq(&group.children().unwrap()[0], &twice));
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply as Filter",
|
||||
["Object", "Plug-ins", "Color Effects"],
|
||||
None,
|
||||
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
|
||||
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
|
||||
has_selection,
|
||||
filter
|
||||
),
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Nearest Swatch",
|
||||
["Object", "Plug-ins", "Color Effects"],
|
||||
None,
|
||||
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
|
||||
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
|
||||
has_selection,
|
||||
nearest::apply
|
||||
),
|
||||
|
||||
@@ -713,6 +713,71 @@ fn corner_commands_refuse_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_commands_refuse_an_unknown_item_key() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let i = id.0;
|
||||
let set = json!({"items": [{"id": i, "anchor": [0]}], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &set).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}");
|
||||
let set_late = json!({"items": [{"id": i, "anchors": [0]}, {"id": i, "radius": 3}], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &set_late).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[1].radius`; the command takes: items[1].id, items[1].anchors"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerSet", &[set, set_late]);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"radius": 10})).unwrap();
|
||||
let remove = json!({"items": [{"id": i, "anchor": [0]}]});
|
||||
let e = s.execute("addon.pathtools.cornerRemove", &remove).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerRemove", &[remove]);
|
||||
assert_eq!(drawn(&s, id, 0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_items_take_their_documented_keys() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let i = id.0;
|
||||
let r = s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i, "anchors": [0]}], "radius": 5})).unwrap();
|
||||
assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), 5.0, 0.0));
|
||||
// A member without `anchors` takes the request's.
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i}], "anchors": [2], "radius": 6})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 2), 6.0);
|
||||
let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i, "anchors": [0]}]})).unwrap();
|
||||
assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 2)), (json!(1), 0.0, 6.0));
|
||||
// A null `ids` beside `items` counts as not given.
|
||||
s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i}], "ids": null})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 2), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_commands_refuse_items_with_ids() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
|
||||
let both = json!({"items": [{"id": id.0, "anchors": [0]}], "ids": [other.0], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &both).unwrap_err().to_string();
|
||||
assert!(e.contains("give `items` or `ids`, not both"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerSet", &[both]);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "radius": 5})).unwrap();
|
||||
let both = json!({"items": [{"id": id.0}], "ids": [id.0]});
|
||||
let e = s.execute("addon.pathtools.cornerRemove", &both).unwrap_err().to_string();
|
||||
assert!(e.contains("give `items` or `ids`, not both"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerRemove", &[both]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_corner_steps_with_older_item_keys_still_play() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
|
||||
let saved = json!({"items": [{"id": id.0, "anchors": [0], "old": 1}], "ids": [other.0], "radius": 5});
|
||||
s.replay("addon.pathtools.cornerSet", &saved).unwrap();
|
||||
assert_eq!((drawn(&s, id, 0), drawn(&s, id, 1)), (5.0, 0.0));
|
||||
s.replay("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "old": 1}]})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 0), 0.0);
|
||||
}
|
||||
|
||||
/// A closed five-pointed star about (200, 150): its points at the even indices 0, 2… (the odd
|
||||
/// numbers counting from 1), its inner corners at the odd ones.
|
||||
fn star_path(s: &mut Session) -> NodeId {
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Live Paint"],
|
||||
Some("Cmd+Alt+X"),
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
|
||||
"{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link); a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_multi_or_ids,
|
||||
lp_merge
|
||||
),
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gap Options",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint; when every group already has the options and closeGaps finds no gap, nothing changes (no undo step) → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
has_selection_or_ids,
|
||||
lp_gap_options
|
||||
),
|
||||
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket",
|
||||
[],
|
||||
None,
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}",
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face}",
|
||||
has_doc,
|
||||
lp_fill
|
||||
),
|
||||
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket (Stroke)",
|
||||
[],
|
||||
None,
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}",
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point; when it already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge}",
|
||||
has_doc,
|
||||
lp_stroke_edge
|
||||
),
|
||||
|
||||
@@ -52,8 +52,34 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
|
||||
Ok((gid, nf, ne))
|
||||
}
|
||||
|
||||
/// Select `ids` alone, without an undo step, when they are not selected that way already.
|
||||
fn select_only(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
let st = s.doc()?;
|
||||
let mut sel = st.selection.clone();
|
||||
sel.set(ids.iter().copied());
|
||||
if sel != st.selection {
|
||||
s.select(|_, selection| *selection = sel)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make or Merge given one Live Paint group and nothing else has nothing to add to it, so it leaves
|
||||
/// the document as it is, records no undo step and selects the group: → its reply. `None` for any
|
||||
/// other `roots`.
|
||||
fn lone_live_group(s: &mut Session, roots: &[NodeId]) -> Result<Option<Value>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let Some(&g) = roots.first().filter(|g| roots.iter().all(|r| r == *g)) else { return Ok(None) };
|
||||
let Some(n) = d.node(g).filter(|n| is_live_paint(n)) else { return Ok(None) };
|
||||
let reply = json!({ "id": g.0, "faces": b::faces(n).len(), "edges": b::edges(n).len() });
|
||||
select_only(s, &[g])?;
|
||||
Ok(Some(reply))
|
||||
}
|
||||
|
||||
pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
if let Some(reply) = lone_live_group(s, &roots)? {
|
||||
return Ok(reply);
|
||||
}
|
||||
let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
@@ -130,6 +156,9 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
|
||||
pub(super) fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
if let Some(reply) = lone_live_group(s, &roots)? {
|
||||
return Ok(reply);
|
||||
}
|
||||
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
@@ -230,27 +259,58 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let close = switch_or(p, "closeGaps", false, C)?;
|
||||
let targets = live_targets(s, p)?;
|
||||
let with_params = |mut options: GapOptions| {
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
options
|
||||
};
|
||||
// When every group already has these options and Close Gaps With Paths has no gap to close,
|
||||
// nothing changes: no undo step, and the reply is the same.
|
||||
let unchanged = {
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut faces, mut gaps, mut same) = (0, vec![], true);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
let options = old.gap_options();
|
||||
if with_params(options) != options {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
let open = open_gaps(old, options);
|
||||
if close && !open.is_empty() {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
faces += b::faces(old).len();
|
||||
gaps.extend(open);
|
||||
}
|
||||
same.then_some((faces, gaps))
|
||||
};
|
||||
if let Some((faces, gaps)) = unchanged {
|
||||
select_only(s, &targets)?;
|
||||
return Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": 0 }));
|
||||
}
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
let mut changed = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut options = old.gap_options();
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
let options = with_params(old.gap_options());
|
||||
let mut sources = source_nodes(&old);
|
||||
let mut added = 0;
|
||||
if close {
|
||||
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
|
||||
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
|
||||
let line = PathData::single(SubPath::polyline(&gap, false));
|
||||
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
|
||||
closed += 1;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
closed += added;
|
||||
let mut n = old.clone();
|
||||
// The faces change only with what paint stops at, or with new paths.
|
||||
if close || options.max_gap() != old.gap_options().max_gap() {
|
||||
if added > 0 || options.max_gap() != old.gap_options().max_gap() {
|
||||
let (children, _, _) = build_children(d, sources, options.max_gap())?;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
@@ -305,27 +365,9 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
|
||||
let group = object_param(p, "group", C)?;
|
||||
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
|
||||
let (g, face) = s.edit("Live Paint Bucket", |d, sel| {
|
||||
let gid = match group {
|
||||
Some(g) => {
|
||||
if !d.node(g).is_some_and(is_live_paint) {
|
||||
return Err(bad(C, format!("{g} is not a Live Paint group")));
|
||||
}
|
||||
g
|
||||
}
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => g,
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => make_live(d, sel, &roots)?.0,
|
||||
}
|
||||
}
|
||||
};
|
||||
// The faces the click or drag paints in Live Paint group `gid`: the visible face at the point,
|
||||
// and every visible face the drag crosses; → (the face at the point, the faces).
|
||||
let targets_in = |d: &Document, gid: NodeId| -> Result<(NodeId, std::collections::BTreeSet<NodeId>)> {
|
||||
let fid = d
|
||||
.node(gid)
|
||||
.and_then(|n| b::visible_face_at(n, pt))
|
||||
@@ -355,11 +397,58 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied()));
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
Ok((fid, targets))
|
||||
};
|
||||
// The Live Paint group the fill paints; `None` when the objects are made into one first.
|
||||
let live = {
|
||||
let d = &s.doc()?.doc;
|
||||
match group {
|
||||
Some(g) => {
|
||||
if !d.node(g).is_some_and(is_live_paint) {
|
||||
return Err(bad(C, format!("{g} is not a Live Paint group")));
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => Some(g),
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?;
|
||||
};
|
||||
let (g, face) = match live {
|
||||
Some(gid) => {
|
||||
let (fid, targets) = targets_in(&s.doc()?.doc, gid)?;
|
||||
// Faces that already have the paint stay as they are: when every face has it, nothing
|
||||
// changes and no undo step is recorded.
|
||||
let d = &s.doc()?.doc;
|
||||
let unpainted: Vec<NodeId> = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect();
|
||||
if unpainted.is_empty() {
|
||||
return Ok(json!({ "group": gid.0, "face": fid.0 }));
|
||||
}
|
||||
s.edit("Live Paint Bucket", |d, _| {
|
||||
for target in unpainted {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?
|
||||
}
|
||||
None => s.edit("Live Paint Bucket", |d, sel| {
|
||||
let gid = make_live(d, sel, &roots)?.0;
|
||||
let (fid, targets) = targets_in(d, gid)?;
|
||||
for target in targets {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?,
|
||||
};
|
||||
s.remember_paint(&paint);
|
||||
Ok(json!({ "group": g.0, "face": face.0 }))
|
||||
}
|
||||
@@ -375,9 +464,22 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "width out of range"));
|
||||
}
|
||||
let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let (eid, unchanged) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
||||
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
let e = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
// An edge that already has the paint and the width (or has no stroke and gets none) stays as
|
||||
// it is, and no undo step is recorded.
|
||||
let unchanged = match e.appearance.stroke() {
|
||||
Some(st) => st.paint == paint && st.width == width,
|
||||
None => paint.is_none(),
|
||||
};
|
||||
(e.id, unchanged)
|
||||
};
|
||||
if unchanged {
|
||||
return Ok(json!({ "group": g.0, "edge": eid.0 }));
|
||||
}
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?;
|
||||
e.appearance.set_stroke(paint.clone());
|
||||
if let Some(st) = e.appearance.stroke_mut() {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Invert Colors",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
|
||||
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
|
||||
),
|
||||
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Front to Back",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}",
|
||||
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md); no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Stack)
|
||||
),
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Horizontally",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost → {changed}",
|
||||
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost; no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Horizontal)
|
||||
),
|
||||
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Vertically",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost → {changed}",
|
||||
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost; no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Vertical)
|
||||
),
|
||||
@@ -457,8 +457,8 @@ pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str,
|
||||
run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p, cmd)?, f, links))
|
||||
}
|
||||
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
|
||||
/// errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`,
|
||||
/// or none when the pass changes nothing; errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
|
||||
/// selection left out ([`left_out`]) that the pass would have changed.
|
||||
fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result<Value> {
|
||||
let (ids, left) = match ids_param(p, "ids", cmd)? {
|
||||
@@ -471,20 +471,47 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
|
||||
}
|
||||
};
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let (n, not_changed) = s.edit(label, |d, _| {
|
||||
let (n, not_changed) = edit_if_changed(s, label, |_, d| {
|
||||
let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden));
|
||||
let n = keeping_series_paint(d, &ids, |d| {
|
||||
let n = pass.run(d, &ids);
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok((n, not_changed))
|
||||
Ok(((n, not_changed), n > 0))
|
||||
})?;
|
||||
let mut out = json!({ "changed": n });
|
||||
not_changed.report(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`Session::edit`] for an Edit Colors pass, which may change nothing: `f` runs on a copy of the
|
||||
/// document (it gets the document as it is too) and returns its result with whether it changed the
|
||||
/// copy. Only a changed copy becomes the document, as one undo step named `label`; otherwise the
|
||||
/// undo and redo steps and the saved state stay as they were.
|
||||
pub(crate) fn edit_if_changed<T>(s: &mut Session, label: &str, f: impl FnOnce(&Document, &mut Document) -> Result<(T, bool)>) -> Result<T> {
|
||||
if s.script_running() {
|
||||
return Err(EngineError::Busy);
|
||||
}
|
||||
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
|
||||
// copy holds what was typed.
|
||||
if !s.previewing && super::typing_in_progress(s) {
|
||||
super::settle_interaction(s)?;
|
||||
}
|
||||
let before = s.doc()?.doc.clone();
|
||||
let mut doc = (*before).clone();
|
||||
let (v, changed) = f(&before, &mut doc)?;
|
||||
// The document is shared with `before` no longer, so `s.edit` does not copy it again.
|
||||
drop(before);
|
||||
if changed {
|
||||
s.edit(label, |d, _| {
|
||||
*d = doc;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed
|
||||
/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn
|
||||
/// again.
|
||||
@@ -597,11 +624,6 @@ fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result<Targets> {
|
||||
fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colors.toIndexed";
|
||||
let indexing = Indexing::of(s, p)?;
|
||||
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
|
||||
// copy holds what was typed.
|
||||
if !s.previewing && super::typing_in_progress(s) {
|
||||
super::settle_interaction(s)?;
|
||||
}
|
||||
let remake = |b: &ImageBlob| indexing.convert(b);
|
||||
let images = Scope { fill: false, stroke: false, images: true, patterns: false };
|
||||
let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) };
|
||||
@@ -609,22 +631,18 @@ fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key.
|
||||
let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![]));
|
||||
let (ids, left) = pass_targets(s, p, C)?;
|
||||
let st = s.doc()?;
|
||||
let colorized_ok = super::paint::check_colorized(&st.doc).is_ok();
|
||||
let not_changed = NotChanged::of(
|
||||
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden),
|
||||
);
|
||||
let mut doc = (*st.doc).clone();
|
||||
let n = pass.run(&mut doc, &ids);
|
||||
if let Some((w, h)) = indexing.too_large.get() {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
|
||||
}
|
||||
if n > 0 {
|
||||
s.edit("Convert to Indexed Color", |d, _| {
|
||||
*d = doc;
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
}
|
||||
let (n, not_changed) = edit_if_changed(s, "Convert to Indexed Color", |now, doc| {
|
||||
let colorized_ok = super::paint::check_colorized(now).is_ok();
|
||||
let not_changed = NotChanged::of(
|
||||
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(now, &left).into_iter().map(|(_, hidden)| hidden),
|
||||
);
|
||||
let n = pass.run(doc, &ids);
|
||||
if let Some((w, h)) = indexing.too_large.get() {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
|
||||
}
|
||||
super::paint::check_colorized_kept(colorized_ok, doc).map_err(|e| bad(C, e))?;
|
||||
Ok(((n, not_changed), n > 0))
|
||||
})?;
|
||||
let mut out = json!({ "changed": n });
|
||||
not_changed.report(&mut out);
|
||||
Ok(out)
|
||||
@@ -923,7 +941,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?;
|
||||
Ok(changed)
|
||||
};
|
||||
let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?;
|
||||
let changed = edit_if_changed(s, label, |_, d| keeping_series_paint(d, &ids, run).map(|n| (n, n > 0)))?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
|
||||
@@ -781,7 +781,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two
|
||||
// brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type
|
||||
// as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them.
|
||||
let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect();
|
||||
let mut kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = HashMap::new();
|
||||
for b in &library {
|
||||
kinds.entry(b.name.as_str()).or_insert(&b.kind);
|
||||
}
|
||||
let brush_entries: Vec<Value> =
|
||||
brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect();
|
||||
let brush_rows: Vec<(String, String)> = brushes
|
||||
|
||||
@@ -25,7 +25,7 @@ use super::dimension::EndedLink;
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::pathops::{assemble, node_path, outlinable, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_selection, number_or, switch_or, text_param};
|
||||
|
||||
const C: &str = "object.expand";
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines (its own fills and strokes on paths of its whole outline, which Fill and Stroke expand), live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Options",
|
||||
[],
|
||||
None,
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character, or of the whole type for the type object's own fills), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -189,21 +189,37 @@ impl Expandable {
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
// Type's outlines take its characters' stroke and fill, and a path of its whole outline
|
||||
// takes the type object's own fills and strokes (see `type.createOutlines`).
|
||||
// Any stroke Outline Stroke outlines, not only the topmost one.
|
||||
let own_stroke = c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st)));
|
||||
let own_pattern = c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some());
|
||||
let own_gradient = c.appearance.items.iter().any(|i| gradient_fill(i).is_some());
|
||||
// Type without an outline (empty, or only spaces) gets no path for its own fills and strokes.
|
||||
let whole = match &c.kind {
|
||||
NodeKind::Text(t) if own_stroke || own_pattern || own_gradient => text_whole_outline(t),
|
||||
_ => None,
|
||||
};
|
||||
e.stroke |= match &c.kind {
|
||||
// Type's outlines take its characters' stroke (see `type.createOutlines`).
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && !c.appearance.stroke_paint().is_none(),
|
||||
NodeKind::Text(t) => (own_stroke && whole.is_some()) || t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && own_stroke,
|
||||
};
|
||||
// Once one is found, the next path is not measured.
|
||||
e.gradient = e.gradient
|
||||
|| match &c.kind {
|
||||
NodeKind::Text(t) => text_gradient_expands(d, t),
|
||||
NodeKind::Text(t) => {
|
||||
text_gradient_expands(d, t)
|
||||
|| (own_gradient && whole.as_ref().is_some_and(|(path, tb)| text_object_gradient_expands(d, c, path, *tb)))
|
||||
}
|
||||
_ => shape && gradient_expands(d, c),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
|
||||
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
|
||||
NodeKind::Text(t) => {
|
||||
(own_pattern && whole.is_some())
|
||||
|| t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some()))
|
||||
}
|
||||
_ => shape && own_pattern,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -275,6 +291,33 @@ fn text_gradient_expands(d: &Document, t: &TextObject) -> bool {
|
||||
|| vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar))
|
||||
}
|
||||
|
||||
/// The path of type `t`'s whole outline in the document (glyphs, ruby glyphs and underline and
|
||||
/// strikethrough bars), which `type.createOutlines` paints with the type object's own fills and
|
||||
/// strokes, and the layout's bounds there; `None` when the type has no outline.
|
||||
fn text_whole_outline(t: &TextObject) -> Option<(PathData, Rect)> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let mut all = BezPath::new();
|
||||
for g in lay.glyphs.iter().chain(&lay.ruby) {
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
for (_, bar) in vectorcraft_text::decorations(&lay, db, t) {
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
let path = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
(!path.is_empty()).then(|| (path, t.xf.transform_rect_bbox(lay.bounds)))
|
||||
}
|
||||
|
||||
/// Whether type node `n` has a gradient fill of its own (not its characters') that Fill expands
|
||||
/// once Object has outlined it: [`gradient_expands`] on `path`, the type's whole outline
|
||||
/// ([`text_whole_outline`]), with its unplaced gradients fixed to `tb`, the layout's bounds in the
|
||||
/// document. Leaves the document as it is.
|
||||
fn text_object_gradient_expands(d: &Document, n: &Node, path: &PathData, tb: Rect) -> bool {
|
||||
let mut appearance = Appearance { items: n.appearance.items.clone(), ..Default::default() };
|
||||
appearance.pin_gradients(tb);
|
||||
gradient_expands(d, &Node::path(NodeId(u64::MAX), path.clone(), appearance))
|
||||
}
|
||||
|
||||
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
|
||||
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
|
||||
|
||||
@@ -6182,3 +6182,66 @@ fn graph_commands_refuse_params_of_the_wrong_type() {
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, null], [2, 3]], "series": null, "transpose": null})).unwrap();
|
||||
assert_eq!(spec(&s).cells(), vec![vec![Some(1.0), None], vec![Some(2.0), Some(3.0)]]);
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A)
|
||||
/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`
|
||||
/// says its data and settings aren't written, in either form.
|
||||
#[test]
|
||||
fn a_graph_opened_from_ai_keeps_its_data_for_editing() {
|
||||
let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\
|
||||
%_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\
|
||||
%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\
|
||||
u\nu\n0.6 0 1 0 k\n110 100 m\n110 150 L\n130 150 L\n130 100 L\nf\nU\n%_2 1 0 0 Go\n\
|
||||
u\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\nU\n%_GS\n";
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 400 400\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 400 400\n%%EndComments\n\
|
||||
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Graph) Ln\n0 A\n{graph}LB\n\
|
||||
%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"
|
||||
);
|
||||
let dir = vectorcraft_testkit::temp_dir("graph-ai-open");
|
||||
let path = dir.join("graph.ai");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let mut found = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.extras().graph.is_some() {
|
||||
found.push(n.id);
|
||||
}
|
||||
});
|
||||
assert_eq!(found.len(), 1);
|
||||
let id = found[0];
|
||||
let before = spec(&s, id);
|
||||
assert_eq!(
|
||||
(before.series.as_slice(), before.categories.as_slice(), before.rows.as_slice()),
|
||||
(&["East".to_string()][..], &["Q1".to_string()][..], &[vec![5.0]][..])
|
||||
);
|
||||
// The fill of the series' first mark.
|
||||
let series_fill = |s: &Session| {
|
||||
let mut fill = None;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.extras().series_index == Some(0) && fill.is_none() {
|
||||
let first = n.children().and_then(|c| c.first().cloned());
|
||||
fill = first.and_then(|m| m.appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint().clone()));
|
||||
}
|
||||
});
|
||||
fill
|
||||
};
|
||||
let painted = series_fill(&s);
|
||||
assert!(painted.is_some());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap();
|
||||
let after = spec(&s, id);
|
||||
assert_eq!(after.rows, [[8.0], [2.0]]);
|
||||
assert_eq!(after.rect, before.rect, "the graph is redrawn on its plot");
|
||||
assert_eq!(series_fill(&s), painted, "the series keeps the file's paint");
|
||||
for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] {
|
||||
let mut p = json!({"path": dir.join(name), "format": "ai"});
|
||||
if let Some(v) = version {
|
||||
p["aiVersion"] = json!(v);
|
||||
}
|
||||
let r = s.execute("file.saveAs", &p).unwrap();
|
||||
let notes = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Invert",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} 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 an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} 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 an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md); no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
|
||||
),
|
||||
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Complement",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} 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 and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} 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 and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||||
),
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! save or Export for Screens: those are checked against the options of the formats the command
|
||||
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the
|
||||
//! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of
|
||||
//! `object.setLiveShape`.
|
||||
//! `object.setLiveShape` and `addon.pathtools.cornerSet`.
|
||||
//!
|
||||
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
|
||||
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
|
||||
@@ -256,6 +256,22 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters a command takes instead of one another: (command, key, other key). Giving both,
|
||||
/// neither null, is a parameter error ([`check_exclusive_keys`]).
|
||||
const EXCLUSIVE_KEYS: &[(&str, &str, &str)] = &[("addon.pathtools.cornerSet", "items", "ids"), ("addon.pathtools.cornerRemove", "items", "ids")];
|
||||
|
||||
/// `params` of command `id` give at most one of each pair in [`EXCLUSIVE_KEYS`], or the parameter
|
||||
/// error naming the pair.
|
||||
fn check_exclusive_keys(id: &str, params: &Value) -> Result<()> {
|
||||
let given = |key: &str| params.get(key).filter(|v| !v.is_null());
|
||||
for (_, key, other) in EXCLUSIVE_KEYS.iter().filter(|(c, ..)| *c == id) {
|
||||
if given(key).is_some() && given(other).is_some() {
|
||||
return Err(bad(id, format!("give `{key}` or `{other}`, not both")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
|
||||
/// them, or all of them.
|
||||
pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
@@ -273,7 +289,11 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError
|
||||
|
||||
/// Commands whose params hold an array of objects with keys of their own: the command, the key of
|
||||
/// the array and the keys each of its objects takes.
|
||||
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])];
|
||||
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[
|
||||
("object.setLiveShape", "items", &["id", "corners"]),
|
||||
("addon.pathtools.cornerSet", "items", &["id", "anchors"]),
|
||||
("addon.pathtools.cornerRemove", "items", &["id", "anchors"]),
|
||||
];
|
||||
|
||||
/// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or
|
||||
/// the parameter error naming the first other one with its place (`items[0].corner`), and the
|
||||
@@ -848,9 +868,9 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> {
|
||||
check_option_keys(id, "", given, &keys, why_not, &trees)
|
||||
}
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an
|
||||
/// export. A command the engine doesn't have passes: running it reports that.
|
||||
/// [`check_keys`], [`check_exclusive_keys`] and [`check_member_keys`] for an engine command, the steps of a `command.batch` or an
|
||||
/// `action.batch` included, [`check_effect_params`] for the effect `effect.apply` adds and
|
||||
/// [`check_export_options`] for an export. A command the engine doesn't have passes: running it reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
check_params_in(id, params, false)
|
||||
}
|
||||
@@ -869,6 +889,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)?;
|
||||
check_exclusive_keys(id, params)?;
|
||||
check_member_keys(id, params)?;
|
||||
if in_batch {
|
||||
crate::schemas::check(spec, params, false)?;
|
||||
|
||||
@@ -962,7 +962,7 @@ pub(crate) fn ordered_map<T: Sync, R: Send>(
|
||||
// ---------- Outline Stroke ----------
|
||||
|
||||
/// Can Outline Stroke turn `st` into filled art?
|
||||
fn outlinable(st: &StrokeLayer) -> bool {
|
||||
pub(crate) fn outlinable(st: &StrokeLayer) -> bool {
|
||||
st.visible && !st.paint.is_none() && st.width > 0.0
|
||||
}
|
||||
|
||||
|
||||
@@ -793,7 +793,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Preferences",
|
||||
[],
|
||||
None,
|
||||
"{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder, glyphSnap and pixelSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}",
|
||||
"{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder, glyphSnap and pixelSnap groups, without the folder preferences fontsFolder, pluginsFolder, scratchPrimary, scratchSecondary, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}",
|
||||
always,
|
||||
export
|
||||
),
|
||||
@@ -802,7 +802,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Preferences",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: the file's text} apply the preferences of a file prefs.export wrote (at most 1 MB). Every value is checked first, as prefs.set checks it: another format, a newer version, an unknown preference, a value of the wrong type or out of range, or a folder preference is an error, and nothing changes. Preferences the file leaves out keep their values; the open document's units stay as they are; no undo step; not a command.batch step → {imported: count}",
|
||||
"{path? | data?: the file's text} apply the preferences of a file prefs.export wrote (at most 1 MB). Every value is checked first, as prefs.set checks it: another format, a newer version, an unknown preference, a value of the wrong type or out of range, or a folder preference (for scratchPrimary and scratchSecondary, a folder; the choices Startup and None are read) is an error, and nothing changes. Preferences the file leaves out keep their values; the open document's units stay as they are; no undo step; not a command.batch step → {imported: count}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
@@ -818,8 +818,20 @@ pub const PREFS_VERSION: u64 = 1;
|
||||
/// The largest preferences file `prefs.import` reads.
|
||||
const MAX_PREFS_FILE_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Folder preferences, which name folders on one computer: a preferences file leaves them out.
|
||||
const FOLDER_PREFS: &[&str] = &["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
|
||||
/// Folder preferences, which name folders on one computer: a preferences file leaves them out, and
|
||||
/// under automation read roots one outside them is not shown.
|
||||
pub const FOLDER_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The scratch disk choices that are not folders.
|
||||
pub const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||||
|
||||
/// Does `value`, set as preference `key`, name a folder: is `key` a folder preference
|
||||
/// ([`FOLDER_PREFS`]) and `value`, trimmed as the app uses it, neither empty (the default folder)
|
||||
/// nor a scratch disk choice ([`SCRATCH_CHOICES`])?
|
||||
pub fn names_a_folder(key: &str, value: &str) -> bool {
|
||||
let path = value.trim();
|
||||
FOLDER_PREFS.contains(&key) && !path.is_empty() && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path))
|
||||
}
|
||||
|
||||
/// The preferences `prefs.export` writes: every [`PREF_SPECS`] key but the [`FOLDER_PREFS`], and
|
||||
/// the [`PREF_GROUPS`].
|
||||
@@ -868,7 +880,9 @@ fn read_prefs_file(bytes: &[u8]) -> std::result::Result<Map<String, Value>, Stri
|
||||
if prefs.is_empty() {
|
||||
return Err("the file holds no preferences".into());
|
||||
}
|
||||
if let Some(key) = prefs.keys().find(|k| FOLDER_PREFS.contains(&k.as_str())) {
|
||||
// A scratch disk choice or an empty scratch disk names no folder, and is read.
|
||||
let folder = |k: &str, v: &Value| FOLDER_PREFS.contains(&k) && (!k.starts_with("scratch") || v.as_str().is_some_and(|v| names_a_folder(k, v)));
|
||||
if let Some(key) = prefs.iter().find(|(k, v)| folder(k, v)).map(|(k, _)| k) {
|
||||
return Err(format!("`{key}` names a folder, which preferences files leave out"));
|
||||
}
|
||||
Ok(prefs.clone())
|
||||
@@ -893,14 +907,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "imported": values.len() }))
|
||||
}
|
||||
|
||||
/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks.
|
||||
const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The scratch disk choices that are not folders.
|
||||
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||||
|
||||
/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the
|
||||
/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside
|
||||
/// request being served. Under automation read roots, a folder preference ([`FOLDER_PREFS`]) outside
|
||||
/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is
|
||||
/// left out. An empty value (the default folder) and a scratch
|
||||
/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are.
|
||||
@@ -911,7 +919,7 @@ fn shown_prefs(prefs: &Prefs) -> Value {
|
||||
}
|
||||
// The app trims a folder before it uses it.
|
||||
let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err();
|
||||
for key in PATH_PREFS {
|
||||
for key in FOLDER_PREFS {
|
||||
let Some(path) = all[key].as_str() else { continue };
|
||||
if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) {
|
||||
all[key] = Value::Null;
|
||||
|
||||
@@ -15,7 +15,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out};
|
||||
use super::colorcmds::{NotChanged, Recolor, Scope, edit_if_changed, keeping_series_paint, left_out};
|
||||
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Recolor Artwork",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
|
||||
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -1661,7 +1661,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let rename = text_param(p, "rename", C)?;
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let (changed, edit, added, converted, not_changed) = s.edit(label, |d, _| {
|
||||
let (changed, edit, added, converted, not_changed) = edit_if_changed(s, label, |now, d| {
|
||||
// The rows' library swatches join the document (one it has with that name and colour is
|
||||
// used as it is).
|
||||
let mut added = vec![];
|
||||
@@ -1716,7 +1716,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let not_changed = NotChanged::of(
|
||||
before.iter().filter(|(id, colors, _)| d.node(*id).is_some_and(|now| shown_colors(d, now) == *colors)).map(|(_, _, hidden)| *hidden),
|
||||
);
|
||||
Ok((changed, edit, added, map.converted.get(), not_changed))
|
||||
// Rewriting the group's swatches and relinking its art are not counted in `changed`, so a
|
||||
// pass that counts nothing compares the documents.
|
||||
let any = changed > 0 || !added.is_empty() || *d != *now;
|
||||
Ok(((changed, edit, added, map.converted.get(), not_changed), any))
|
||||
})?;
|
||||
let mut out = json!({ "changed": changed, "swatches": added });
|
||||
not_changed.report(&mut out);
|
||||
|
||||
@@ -34,8 +34,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The Offset a wrap object takes, in points.
|
||||
pub const TEXT_WRAP_OFFSET_RANGE: std::ops::RangeInclusive<f64> = -1000.0..=1000.0;
|
||||
|
||||
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
|
||||
let (lo, hi) = (*TEXT_WRAP_OFFSET_RANGE.start(), *TEXT_WRAP_OFFSET_RANGE.end());
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(lo, hi), invert: bool_or(p, "invert", base.invert) }
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -39,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Outlines",
|
||||
["Type"],
|
||||
Some("Cmd+Shift+O"),
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); the type object's own fills and strokes (not its characters') go on paths of its whole outline, below or above the glyphs as its Characters row places them; a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
@@ -672,13 +674,39 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
placed.extend(ruby.map(|(_, r)| r));
|
||||
let mut children = placed;
|
||||
// The whole outline the object's own fills and strokes paint: every glyph, ruby glyph
|
||||
// and bar, as the canvas paints them.
|
||||
let mut all = kurbo::BezPath::new();
|
||||
for g in lay.glyphs.iter().chain(&lay.ruby) {
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
// Underline and strikethrough bars become paths of their run's paint too (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
|
||||
all.extend(bar.iter());
|
||||
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
|
||||
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
|
||||
node.appearance = st.appearance().into();
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
// The object's own fills and strokes (not its characters') paint the whole outline:
|
||||
// those below the Characters row under the characters, the others over them, with
|
||||
// their unplaced gradients fixed to the layout's bounds in the document.
|
||||
let all = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
if !n.appearance.items.is_empty() && !all.is_empty() {
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let tb = t.xf.transform_rect_bbox(lay.bounds);
|
||||
let mut whole = |items: &[AppearanceItem]| {
|
||||
let mut ap = Appearance { items: items.to_vec(), ..Default::default() };
|
||||
ap.pin_gradients(tb);
|
||||
Arc::new(Node::path(d.alloc_id(), all.clone(), ap))
|
||||
};
|
||||
if !below.is_empty() {
|
||||
children.insert(0, whole(below));
|
||||
}
|
||||
if !above.is_empty() {
|
||||
children.push(whole(above));
|
||||
}
|
||||
}
|
||||
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
|
||||
d.remove(tid)?;
|
||||
if children.is_empty() {
|
||||
|
||||
@@ -867,7 +867,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Data Sets…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{path? | data? | dataBase64? | csv?} replace every dataset with the rows of a CSV, comma- or tab-separated, in UTF-16 with a byte-order mark, UTF-8 or else Windows-1252 (header: one variable name per column, whatever its case; one row per dataset, named Data Set 1, 2… in file order). The document's variables and their bindings stay. A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so are two headers that name one variable and a header that would define a variable while the variables are locked. An import that leaves the variables and data sets as they were adds no undo step → {imported, names, defined, deletedDataSets}",
|
||||
"{path? | data? | dataBase64? | csv?} replace every dataset with the rows of a CSV, comma- or tab-separated, in UTF-16 (with a byte-order mark, or without one when at least half of its two-byte units hold a NUL byte and only one byte order reads a line break; any other file with NUL bytes is refused), UTF-8 or else Windows-1252 (header: one variable name per column, whatever its case; one row per dataset, named Data Set 1, 2… in file order). The document's variables and their bindings stay. A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so are two headers that name one variable and a header that would define a variable while the variables are locked. An import that leaves the variables and data sets as they were adds no undo step → {imported, names, defined, deletedDataSets}",
|
||||
has_doc,
|
||||
dataset_import
|
||||
),
|
||||
|
||||
@@ -32,20 +32,17 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<(Vec<u8>, bool)> {
|
||||
}
|
||||
|
||||
/// The text of a datasets CSV given as a file or as `dataBase64`: UTF-16 after a UTF-16
|
||||
/// byte-order mark, else UTF-8, with or without a byte-order mark, else Windows-1252. A file
|
||||
/// that starts with a byte-order mark must be valid in its encoding, and a file without one must
|
||||
/// not hold NUL bytes (UTF-16 without a byte-order mark).
|
||||
/// byte-order mark, else UTF-16 without one when the file holds NUL bytes (see
|
||||
/// [`utf16_without_mark`]), else UTF-8, with or without a byte-order mark, else Windows-1252. A
|
||||
/// file that starts with a byte-order mark must be valid in its encoding, and a file with NUL
|
||||
/// bytes and no byte-order mark must read as UTF-16 by [`utf16_without_mark`]'s rule.
|
||||
fn datasets_csv_text(bytes: &[u8], cmd: &str) -> Result<String> {
|
||||
let utf16 = |rest: &[u8], le: bool| {
|
||||
let (units, odd) = rest.as_chunks::<2>();
|
||||
let units = units.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
|
||||
let text = char::decode_utf16(units).collect::<std::result::Result<String, _>>();
|
||||
text.ok().filter(|_| odd.is_empty()).ok_or_else(|| bad(cmd, "CSV is not UTF-16"))
|
||||
};
|
||||
match bytes {
|
||||
[0xFF, 0xFE, rest @ ..] => return utf16(rest, true),
|
||||
[0xFE, 0xFF, rest @ ..] => return utf16(rest, false),
|
||||
_ if bytes.contains(&0) => return Err(bad(cmd, "CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark")),
|
||||
[0xFF, 0xFE, rest @ ..] => return utf16(rest, true).ok_or_else(|| bad(cmd, "CSV is not UTF-16")),
|
||||
[0xFE, 0xFF, rest @ ..] => return utf16(rest, false).ok_or_else(|| bad(cmd, "CSV is not UTF-16")),
|
||||
_ if bytes.contains(&0) => {
|
||||
return utf16_without_mark(bytes).ok_or_else(|| bad(cmd, "CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match std::str::from_utf8(bytes) {
|
||||
@@ -55,6 +52,34 @@ fn datasets_csv_text(bytes: &[u8], cmd: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` as UTF-16, little-endian when `le` is set, or `None` when they aren't valid UTF-16.
|
||||
fn utf16(bytes: &[u8], le: bool) -> Option<String> {
|
||||
let (units, odd) = bytes.as_chunks::<2>();
|
||||
let units = units.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
|
||||
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok().filter(|_| odd.is_empty())
|
||||
}
|
||||
|
||||
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of
|
||||
/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with
|
||||
/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is
|
||||
/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or
|
||||
/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either
|
||||
/// order has no data rows, and is read little-endian so that the import reports that. The text
|
||||
/// must be valid UTF-16 in that order and hold no NUL character.
|
||||
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
|
||||
let (units, odd) = bytes.as_chunks::<2>();
|
||||
if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
|
||||
return None;
|
||||
}
|
||||
let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D);
|
||||
let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c)));
|
||||
let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c)));
|
||||
if lines_le && lines_be {
|
||||
return None;
|
||||
}
|
||||
utf16(bytes, !lines_be).filter(|text| !text.contains('\0'))
|
||||
}
|
||||
|
||||
/// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted.
|
||||
type CsvRow = (usize, Vec<String>, Vec<bool>);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ pub use cmd::params::{check_keys, check_params};
|
||||
pub use cmd::pathops::auto_simplify_level;
|
||||
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
|
||||
pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step};
|
||||
pub use cmd::textwrap::TEXT_WRAP_OFFSET_RANGE;
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use cmd::{EyedropperOptions, PathfinderOptions};
|
||||
pub use journal::Journal;
|
||||
|
||||
@@ -1748,6 +1748,203 @@ fn shape_builder_arrow_keys_pass_swatches_of_the_same_color() {
|
||||
assert_eq!(s.paint.fill, after, "the swatch after Twin B");
|
||||
}
|
||||
|
||||
/// The colors of the cursor swatch preview's chips, left to right, and which one is ringed.
|
||||
fn swatch_chips(s: &mut Session, v: ViewInfo) -> Vec<([u8; 4], bool)> {
|
||||
let mut chips: Vec<(f64, [u8; 4], bool)> = s
|
||||
.overlays(v)
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
vectorcraft_tools::Overlay::Swatch { p, color, selected } => Some((p.x, color, selected)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
chips.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
chips.into_iter().map(|(_, c, sel)| (c, sel)).collect()
|
||||
}
|
||||
|
||||
/// A solid paint's color as the chips draw it.
|
||||
fn chip_color(p: &Paint) -> [u8; 4] {
|
||||
match p {
|
||||
Paint::Solid { color, .. } => color.to_rgba8(1.0),
|
||||
_ => panic!("not a solid color: {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Live Paint Bucket: Left and Right step the active paint through the document's color swatches
|
||||
/// (the selected art keeps its paint), the Cursor Swatch Preview (on at first) shows the active
|
||||
/// paint ringed between the swatches before and after it, and with it off the arrows still step.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches() {
|
||||
let mut s = session();
|
||||
let (a, _) = two(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
assert_eq!(s.tool_options_of("livePaintBucket")["cursorSwatchPreview"], json!(true));
|
||||
assert!(s.tool_claims_key(ToolKey::Right, v) && s.tool_claims_key(ToolKey::Left, v));
|
||||
assert!(!s.tool_claims_key(ToolKey::Up, v), "Up and Down keep their own use");
|
||||
let list: Vec<(String, Paint)> =
|
||||
vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| (sw.name.clone(), sw.paint.clone())).collect();
|
||||
assert!(list.len() >= 4);
|
||||
let n = list.len();
|
||||
let stroke = s.paint.stroke.clone();
|
||||
s.execute("paint.setFill", &json!({"swatch": list[0].0, "ids": []})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[2].1);
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[1].1);
|
||||
assert_eq!(s.paint.stroke, stroke, "the stroke stays");
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its fill");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&list[0].1), false), (chip_color(&list[1].1), true), (chip_color(&list[2].1), false)]);
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0).with_mods(alt), v).unwrap();
|
||||
assert!(swatch_chips(&mut s, v).is_empty(), "Alt samples with the eyedropper and shows no chips");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
// Past the first swatch, Left wraps around to the last.
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[n - 1].1);
|
||||
assert_eq!(swatch_chips(&mut s, v).iter().map(|c| c.0).collect::<Vec<_>>(), [&list[n - 2].1, &list[n - 1].1, &list[0].1].map(chip_color));
|
||||
// With the Stroke proxy in front, the arrows step the stroke and the chips show it.
|
||||
s.execute("paint.toggleActive", &json!({"fill": false})).unwrap();
|
||||
let fill = s.paint.fill.clone();
|
||||
s.execute("paint.setStroke", &json!({"swatch": list[1].0, "ids": []})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &list[2].1));
|
||||
assert!(!s.fill_active, "the Stroke proxy stays in front");
|
||||
assert_eq!(swatch_chips(&mut s, v)[1], (chip_color(&list[2].1), true));
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its paint");
|
||||
// Cursor Swatch Preview off hides the chips; the arrows still step.
|
||||
s.set_tool_option_cmd(&json!({"tool": "livePaintBucket", "values": {"cursorSwatchPreview": false}})).unwrap();
|
||||
assert!(swatch_chips(&mut s, v).is_empty());
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, list[3].1);
|
||||
}
|
||||
|
||||
/// The Live Paint Bucket and the Shape Builder take Left and Right for swatches only without
|
||||
/// modifiers: Shift, Alt and Cmd/Ctrl arrows are left to their shortcuts and change no paint.
|
||||
#[test]
|
||||
fn swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts() {
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
let first = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[0].name.clone();
|
||||
s.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap();
|
||||
for tool in ["livePaintBucket", "shapeBuilder"] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
if tool == "shapeBuilder" {
|
||||
s.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "swatches"}})).unwrap();
|
||||
}
|
||||
let fill = s.paint.fill.clone();
|
||||
assert!(s.tool_claims_key_with(ToolKey::Right, Mods::default(), v), "{tool}");
|
||||
for m in [Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }, Mods { cmd: true, ..Default::default() }] {
|
||||
for key in [ToolKey::Left, ToolKey::Right] {
|
||||
assert!(!s.tool_claims_key_with(key, m, v), "{tool} {key:?} {m:?}");
|
||||
s.tool_key(key, m, v).unwrap();
|
||||
assert_eq!(s.paint.fill, fill, "{tool} {key:?} {m:?}");
|
||||
}
|
||||
}
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_ne!(s.paint.fill, fill, "{tool}: a plain Right steps");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two swatches of the same color: the Bucket's arrows step through both, and the fill and the
|
||||
/// stroke each step on from the swatch picked for them.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke() {
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
for name in ["Twin A", "Twin B"] {
|
||||
s.execute("swatch.new", &json!({"name": name, "color": "#123456"})).unwrap();
|
||||
}
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
let names: Vec<String> = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.name.clone()).collect();
|
||||
let a = names.iter().position(|n| n == "Twin A").unwrap();
|
||||
assert_eq!(names[a + 1], "Twin B");
|
||||
let after = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[(a + 2) % names.len()].paint.clone();
|
||||
// The fill goes to Twin B.
|
||||
s.execute("paint.setFill", &json!({"swatch": "Twin A", "ids": []})).unwrap();
|
||||
let twin = s.paint.fill.clone();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, twin, "Twin B");
|
||||
// The stroke starts at Twin A: Right goes to Twin B, then past it.
|
||||
s.execute("paint.setStroke", &json!({"swatch": "Twin A", "ids": []})).unwrap();
|
||||
assert!(!s.fill_active);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, twin, "Twin B for the stroke");
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, after, "the swatch after Twin B");
|
||||
// Back on the fill, which is still Twin B: Right goes past it.
|
||||
s.execute("paint.toggleActive", &json!({"fill": true})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, after, "the fill steps on from Twin B");
|
||||
}
|
||||
|
||||
/// A color that is no swatch shows alone and Right picks the first swatch, Left the last; a paint
|
||||
/// that is not a solid color shows no chip; one swatch steps to itself; with no color swatch the
|
||||
/// tool leaves the arrows alone; during a fill drag the arrows change nothing.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags() {
|
||||
let mut s = session();
|
||||
live(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
let list: Vec<Paint> = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.paint.clone()).collect();
|
||||
let odd = Paint::solid(Color::rgb8(0x12, 0x34, 0x57));
|
||||
assert!(!list.contains(&odd));
|
||||
s.paint.fill = odd.clone();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&odd), true)]);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[0]);
|
||||
s.paint.fill = odd.clone();
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[list.len() - 1]);
|
||||
s.paint.fill = Paint::None;
|
||||
assert!(swatch_chips(&mut s, v).is_empty());
|
||||
|
||||
// During a fill drag the arrows are not taken and change nothing.
|
||||
s.paint.fill = list[0].clone();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
|
||||
assert!(s.tool_busy());
|
||||
assert!(!s.tool_claims_key(ToolKey::Right, v));
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[0]);
|
||||
assert!(swatch_chips(&mut s, v).is_empty(), "no chips during a drag");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 25.0, 50.0), v).unwrap();
|
||||
|
||||
// One color swatch: both arrows pick it, and it stands on both sides of itself.
|
||||
let one = list[0].clone();
|
||||
s.edit("t", |d, _| {
|
||||
d.swatch_groups.clear();
|
||||
d.swatches.retain(|sw| sw.paint == one);
|
||||
d.swatches.truncate(1);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).len(), 1);
|
||||
s.paint.fill = odd;
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 301.0, 300.0), v).unwrap();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&one), false), (chip_color(&one), true), (chip_color(&one), false)]);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
|
||||
// No color swatch: the arrows are left to the canvas and the paint stays.
|
||||
s.edit("t", |d, _| {
|
||||
d.swatches.clear();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!s.tool_claims_key(ToolKey::Right, v) && !s.tool_claims_key(ToolKey::Left, v));
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_alt_deletes_line_pieces() {
|
||||
let mut s = session();
|
||||
@@ -2446,6 +2643,193 @@ fn live_paint_stroke_edge() {
|
||||
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err());
|
||||
}
|
||||
|
||||
/// The undo and redo depths, the unsaved state and the revision of the active document.
|
||||
fn history_state(s: &Session) -> (usize, usize, bool, u64) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty(), st.revision)
|
||||
}
|
||||
|
||||
/// Draw a rectangle and undo it, so a redo step is waiting, then mark the document saved with `g`
|
||||
/// alone selected.
|
||||
fn redo_waiting_and_saved(s: &mut Session, g: NodeId) {
|
||||
rect(s, 300.0, 300.0, 10.0, 10.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// The active document and its [`history_state`].
|
||||
type Snapshot = (vectorcraft_doc::Document, (usize, usize, bool, u64));
|
||||
|
||||
fn snapshot(s: &Session) -> Snapshot {
|
||||
((*s.doc().unwrap().doc).clone(), history_state(s))
|
||||
}
|
||||
|
||||
/// After a Live Paint edit that changed nothing: the document, the undo and redo steps, the saved
|
||||
/// state and the revision are as `before`, and Redo still brings back the rectangle
|
||||
/// [`redo_waiting_and_saved`] undid. Undoes the rectangle again, marks the document saved and
|
||||
/// takes a new `before`.
|
||||
fn assert_unchanged(s: &mut Session, before: &mut Snapshot, what: &str) {
|
||||
assert!(*s.doc().unwrap().doc == before.0, "{what}: the document is as it was");
|
||||
assert_eq!(history_state(s), before.1, "{what}: no undo step, the redo step kept, still saved");
|
||||
let children = layer_children(s);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(layer_children(s), children + 1, "{what}: Redo brings back the rectangle");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
*before = snapshot(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_fill_same_paint_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let drag = json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#00ff00"});
|
||||
s.execute("livePaint.fill", &drag).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
// A color used since stays the newest recent color and the last color.
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
s.remember_paint_now(&Paint::solid(blue));
|
||||
let recent = (s.recent_colors.clone(), s.last_solid);
|
||||
let mut before = snapshot(&s);
|
||||
let click = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "color": "#00ff00"})).unwrap();
|
||||
let face = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(75.0, 50.0)).unwrap().id;
|
||||
assert_eq!(click, json!({"group": g.0, "face": face.0}), "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "a click on a face with the paint");
|
||||
s.execute("livePaint.fill", &drag).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a drag over faces with the paint");
|
||||
// Without a group, the click finds the Live Paint group among the selected objects.
|
||||
s.execute("livePaint.fill", &json!({"point": [125, 50], "color": "#00ff00"})).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a click in the selected group");
|
||||
assert_eq!((s.recent_colors.clone(), s.last_solid), recent, "the recent colors are as they were");
|
||||
// A drag that reaches one face without the paint is one undo step and remembers the color.
|
||||
let red = Paint::solid(Color::from_hex("#ff0000").unwrap());
|
||||
for x in [25, 75] {
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [x, 50], "color": "#ff0000"})).unwrap();
|
||||
}
|
||||
s.remember_paint_now(&Paint::solid(blue));
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
|
||||
assert_eq!(Paint::solid(s.last_solid), red);
|
||||
let gn = node(&s, g);
|
||||
for x in [25.0, 75.0, 125.0] {
|
||||
assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().appearance.fill_paint(), red, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_click_with_the_face_paint_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0));
|
||||
let click = |s: &mut Session, x: f64| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, x, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, x, 50.0), v).unwrap();
|
||||
};
|
||||
click(&mut s, 125.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
click(&mut s, 125.0);
|
||||
assert_unchanged(&mut s, &mut before, "a Bucket click on a face with the paint");
|
||||
// A drag over faces with the paint changes nothing either.
|
||||
click(&mut s, 25.0);
|
||||
click(&mut s, 75.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a Bucket drag over faces with the paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_stroke_edge_same_paint_and_width_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let call = json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 3});
|
||||
let first = s.execute("livePaint.strokeEdge", &call).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
s.remember_paint_now(&Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let last = s.last_solid;
|
||||
let mut before = snapshot(&s);
|
||||
assert_eq!(s.execute("livePaint.strokeEdge", &call).unwrap(), first, "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "an edge with the paint and the width");
|
||||
assert_eq!(s.last_solid, last, "the last color is as it was");
|
||||
// Another width is an edit.
|
||||
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 2})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_options_same_values_are_no_op() {
|
||||
let mut s = session();
|
||||
let g = live_square_with_a_gap(&mut s);
|
||||
let set = json!({"detect": true, "size": "small", "color": "#123456"});
|
||||
let first = s.execute("livePaint.gapOptions", &set).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
assert_eq!(s.execute("livePaint.gapOptions", &set).unwrap(), first, "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "the options the group has");
|
||||
s.execute("livePaint.gapOptions", &json!({})).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "no options given");
|
||||
// Close Gaps With Paths with a gap to close is an edit.
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"closeGaps": true})).unwrap();
|
||||
assert_eq!(r["closed"], 1);
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_options_close_without_gaps_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap();
|
||||
assert_eq!((ids(&r), &r["faces"], &r["closed"], gaps_of(&r).len()), (vec![g], &json!(3), &json!(0), 0));
|
||||
assert_unchanged(&mut s, &mut before, "no gap to close");
|
||||
// With other options, the faces keep their ids when no path is added.
|
||||
let faces = |s: &Session| vectorcraft_tools::builder::faces(&node(s, g)).iter().map(|f| f.id).collect::<Vec<_>>();
|
||||
let ids_before = faces(&s);
|
||||
s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true, "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
assert_eq!(faces(&s), ids_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_make_existing_group_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
let r = s.execute("livePaint.make", &json!({})).unwrap();
|
||||
assert_eq!((NodeId(r["id"].as_u64().unwrap()), &r["faces"]), (g, &json!(3)));
|
||||
assert_eq!(r["edges"].as_u64().unwrap(), vectorcraft_tools::builder::edges(&node(&s, g)).len() as u64);
|
||||
assert_unchanged(&mut s, &mut before, "Make with the group alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_merge_single_group_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let other = rect(&mut s, 300.0, 0.0, 50.0, 50.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
// Given by id, the group is selected without an undo step.
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
let before = snapshot(&s);
|
||||
let r = s.execute("livePaint.merge", &json!({"ids": [g.0]})).unwrap();
|
||||
assert_eq!((NodeId(r["id"].as_u64().unwrap()), &r["faces"]), (g, &json!(3)));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
assert!(*s.doc().unwrap().doc == before.0);
|
||||
let (undo, redo, dirty, _) = history_state(&s);
|
||||
assert_eq!((undo, redo, dirty), (before.1.0, before.1.1, before.1.2));
|
||||
// A path to add is an edit.
|
||||
s.execute("livePaint.merge", &json!({"ids": [g.0, other.0]})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
/// Shape Builder refuses an on/off option that is not true or false and a tolerance or gap that is
|
||||
/// not a number, given as text or as another value of the wrong kind, naming the key; the document
|
||||
/// and the undo history stay as they were. True, false and null still work.
|
||||
@@ -3097,9 +3481,10 @@ fn live_paint_tool_options_refuse_invalid_values_and_change_nothing() {
|
||||
let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)];
|
||||
let widths = [json!("wide"), json!(null), json!(true)];
|
||||
let switches = [json!("true"), json!(null), json!(1)];
|
||||
for (tool, keys) in
|
||||
[("livePaintBucket", &["paintFills", "paintStrokes", "highlight"][..]), ("livePaintSelection", &["selectFills", "selectStrokes"][..])]
|
||||
{
|
||||
for (tool, keys) in [
|
||||
("livePaintBucket", &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight"][..]),
|
||||
("livePaintSelection", &["selectFills", "selectStrokes"][..]),
|
||||
] {
|
||||
let mut bad: Vec<(&str, Value)> = keys.iter().flat_map(|k| switches.iter().map(move |v| (*k, v.clone()))).collect();
|
||||
bad.extend(colors.iter().map(|v| ("color", v.clone())));
|
||||
bad.extend(widths.iter().map(|v| ("width", v.clone())));
|
||||
|
||||
@@ -109,6 +109,48 @@ fn saturate_keeps_a_color_it_does_not_change() {
|
||||
assert_eq!(paint(&s, b), cmyk);
|
||||
}
|
||||
|
||||
/// An Edit Colors pass, a Blend or Recolor Artwork that changes nothing records no undo step, and the
|
||||
/// step that redo would bring back stays; one that changes a color still records one.
|
||||
#[test]
|
||||
fn edit_colors_that_change_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let gray = rect(&mut s, json!("#808080"));
|
||||
let a = rect(&mut s, json!("#ff0000"));
|
||||
let b = rect(&mut s, json!("#808080"));
|
||||
let c = rect(&mut s, json!("#0000ff"));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
|
||||
s.execute("edit.colors.blendFrontToBack", &json!({})).unwrap();
|
||||
// A step for redo to bring back.
|
||||
s.execute("edit.colors.invert", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let history = |s: &Session| {
|
||||
let h = &s.doc().unwrap().history;
|
||||
(h.undo.len(), h.redo.len())
|
||||
};
|
||||
let before = history(&s);
|
||||
assert_eq!(before.1, 1, "one step to redo");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let cases = [
|
||||
("edit.colors.invert", json!({"fill": false, "stroke": false})),
|
||||
("edit.colors.saturate", json!({"intensity": 0})),
|
||||
("edit.colors.toRGB", json!({})),
|
||||
("edit.colors.adjustBalance", json!({"mode": "rgb", "r": 0})),
|
||||
("edit.colors.blendFrontToBack", json!({})),
|
||||
("recolor.apply", json!({"map": {"#ff0000": "#ff0000"}})),
|
||||
// The complement of a gray is the same gray.
|
||||
("paint.complement", json!({"ids": [gray.0], "stroke": false})),
|
||||
("addon.color.filter", json!({"effect": "addon.color.invert", "fill": false, "stroke": false})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let v = s.execute(id, &p).unwrap();
|
||||
assert_eq!(v["changed"], 0, "{id}");
|
||||
assert_eq!(history(&s), before, "{id}: no undo step, and redo is kept");
|
||||
assert_eq!(s.doc().unwrap().doc, doc, "{id}: the document is unchanged");
|
||||
}
|
||||
s.execute("edit.colors.invert", &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0), "a pass that changes colors is one undo step");
|
||||
}
|
||||
|
||||
/// Adjust Color Balance refuses a channel that is not a number, which it would otherwise read as 0.
|
||||
#[test]
|
||||
fn adjust_balance_refuses_a_channel_that_is_not_a_number() {
|
||||
|
||||
@@ -712,6 +712,159 @@ fn type_in_symbol_art_reports_a_gradient_only_when_it_expands() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected type "Hi" at 64 pt whose characters have no paint, with fills and strokes of its own:
|
||||
/// each of `items` is an `appearance.setItem` edit of a new object-level stroke when it has a
|
||||
/// `weight`, else of a new object-level fill; the first `below` of them paint below the characters.
|
||||
fn type_with_own_paint(s: &mut Session, items: &[Value], below: usize) -> NodeId {
|
||||
let t = run(s, "text.create", json!({"x": 25, "y": 110, "text": "Hi", "size": 64}))["id"].as_u64().unwrap();
|
||||
run(s, "select.set", json!({"ids": [t]}));
|
||||
run(s, "paint.setFill", json!({"none": true}));
|
||||
run(s, "paint.setStroke", json!({"none": true}));
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let add = if item.get("weight").is_some() { "appearance.addStroke" } else { "appearance.addFill" };
|
||||
run(s, add, json!({"target": "object"}));
|
||||
let mut p = item.clone();
|
||||
p["index"] = json!(i);
|
||||
run(s, "appearance.setItem", p);
|
||||
}
|
||||
run(s, "appearance.moveItem", json!({"from": "contents", "to": below}));
|
||||
NodeId(t)
|
||||
}
|
||||
|
||||
fn blue_stroke() -> Value {
|
||||
json!({"color": "#0000ff", "weight": 5})
|
||||
}
|
||||
|
||||
fn red_blue() -> Value {
|
||||
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})
|
||||
}
|
||||
|
||||
/// Create Outlines draws what the type drew: a gradient fill of the type object's own, below its
|
||||
/// characters, keeps one gradient across both glyphs over the layout's box, and its stroke stays
|
||||
/// over them.
|
||||
#[test]
|
||||
fn create_outlines_draws_the_type_objects_own_paint() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1);
|
||||
let before = render(&s);
|
||||
run(&mut s, "type.createOutlines", json!({}));
|
||||
let after = render(&s);
|
||||
let n = differing(&before, &after, 24);
|
||||
assert!(n < 40, "{n} pixels differ");
|
||||
assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something");
|
||||
}
|
||||
|
||||
/// `object.expand.info` reports the type object's own stroke, pattern fill and gradient fill, which
|
||||
/// Object turns into paths of its whole outline: a gradient only when that outline's box needs no
|
||||
/// more runs than are written.
|
||||
#[test]
|
||||
fn type_objects_own_stroke_pattern_and_gradient_are_reported() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[blue_stroke()], 0);
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["object"].clone(), info["stroke"].clone(), info["fill"].clone()), (json!(true), json!(true), json!(false)), "{info}");
|
||||
|
||||
let mut s = session();
|
||||
pattern_rect(&mut s, Rect::new(150.0, 150.0, 190.0, 190.0), false);
|
||||
let t = type_with_own_paint(&mut s, &[json!({"swatch": "Dots"})], 0);
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["fill"].clone(), info["gradient"].clone(), info["stroke"].clone()), (json!(true), json!(false), json!(false)), "{info}");
|
||||
|
||||
// 0.4 pt runs across about 80 pt: under the limit; 0.02 pt runs: far over it.
|
||||
for (len, expands) in [(40.0, true), (2.0, false)] {
|
||||
let mut s = session();
|
||||
let gradient = json!({"gradient": {"kind": "linear", "spread": "repeat", "repeats": 100, "start": [25, 100], "end": [25.0 + len, 100],
|
||||
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}});
|
||||
type_with_own_paint(&mut s, &[gradient], 0);
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{len}: {info}");
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let (gradients, texts) = gradients_and_type(&s);
|
||||
assert_eq!(texts, 0, "{len}: outlined");
|
||||
assert_eq!(gradients == 0, expands, "{len}: {gradients} gradient fills left");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type with only spaces has no outline for its own stroke or pattern fill to go on, so
|
||||
/// `object.expand.info` reports neither.
|
||||
#[test]
|
||||
fn type_without_an_outline_reports_no_own_stroke_or_fill() {
|
||||
let mut s = session();
|
||||
pattern_rect(&mut s, Rect::new(150.0, 150.0, 190.0, 190.0), false);
|
||||
let t = run(&mut s, "text.create", json!({"x": 25, "y": 110, "text": " ", "size": 64}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
for (i, item) in [json!({"swatch": "Dots"}), blue_stroke()].into_iter().enumerate() {
|
||||
let add = if item.get("weight").is_some() { "appearance.addStroke" } else { "appearance.addFill" };
|
||||
run(&mut s, add, json!({"target": "object"}));
|
||||
let mut p = item;
|
||||
p["index"] = json!(i);
|
||||
run(&mut s, "appearance.setItem", p);
|
||||
}
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!(
|
||||
(info["object"].clone(), info["stroke"].clone(), info["fill"].clone(), info["gradient"].clone()),
|
||||
(json!(true), json!(false), json!(false), json!(false)),
|
||||
"{info}"
|
||||
);
|
||||
}
|
||||
|
||||
/// How many objects in the document have a visible stroke with paint and weight.
|
||||
fn painted_strokes(s: &Session) -> usize {
|
||||
let mut n = 0;
|
||||
s.doc().unwrap().doc.walk(|c| {
|
||||
n += usize::from(
|
||||
c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.visible && !st.paint.is_none() && st.width > 0.0)),
|
||||
);
|
||||
});
|
||||
n
|
||||
}
|
||||
|
||||
/// A visible stroke under a topmost stroke with no paint still has something for Stroke to
|
||||
/// outline, on type's own appearance and on a path: `object.expand.info` reports it, and Expand
|
||||
/// outlines it.
|
||||
#[test]
|
||||
fn a_stroke_under_an_unpainted_stroke_is_reported_and_outlined() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[blue_stroke(), json!({"none": true, "weight": 1})], 0);
|
||||
let mut r = session();
|
||||
run(&mut r, "shape.rectangle", json!({"x": 40, "y": 40, "width": 100, "height": 80}));
|
||||
run(&mut r, "paint.setStroke", json!({"color": "#0000ff"}));
|
||||
run(&mut r, "stroke.set", json!({"weight": 4}));
|
||||
run(&mut r, "appearance.addStroke", json!({"target": "object"}));
|
||||
let top = node(&r, selected(&r).id).appearance.items.len() - 1;
|
||||
run(&mut r, "appearance.setItem", json!({"index": top, "none": true}));
|
||||
for (what, s) in [("type", &mut s), ("path", &mut r)] {
|
||||
let info = run(s, "object.expand.info", json!({}));
|
||||
assert_eq!(info["stroke"], json!(true), "{what}: {info}");
|
||||
run(s, "object.expand", json!({}));
|
||||
assert_eq!(painted_strokes(s), 0, "{what}: the stroke is outlined");
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand keeps the type object's own stroke and gradient fill through Object: Stroke outlines the
|
||||
/// stroke and Fill expands the gradient, and with those options off they stay live on the path of
|
||||
/// the whole outline. The picture stays the same either way.
|
||||
#[test]
|
||||
fn expand_keeps_the_type_objects_own_paint() {
|
||||
for (opts, live) in [(json!({}), false), (json!({"fill": false, "stroke": false}), true)] {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1);
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", opts.clone());
|
||||
let after = render(&s);
|
||||
assert!(mean_diff(&before, &after) < 2.0, "{opts}: {}", mean_diff(&before, &after));
|
||||
let (gradients, texts) = gradients_and_type(&s);
|
||||
assert_eq!(texts, 0, "{opts}: outlined");
|
||||
assert_eq!(gradients == 1, live, "{opts}: {gradients} gradient fills");
|
||||
let mut strokes = 0;
|
||||
s.doc().unwrap().doc.walk(|n| strokes += usize::from(!n.appearance.stroke_paint().is_none()));
|
||||
assert_eq!(strokes == 1, live, "{opts}: {strokes} strokes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
|
||||
for gradient in [
|
||||
|
||||
@@ -1043,6 +1043,51 @@ fn create_outlines_matches_text_bounds() {
|
||||
assert!(s.doc().unwrap().doc.node(t).is_some());
|
||||
}
|
||||
|
||||
/// The type object's own fills and strokes (not its characters') become paths of its whole outline,
|
||||
/// with ids of their own: those below the Characters row under the glyph paths, the others over
|
||||
/// them, in their order. The group keeps no appearance of its own, and Undo brings the type back.
|
||||
#[test]
|
||||
fn create_outlines_keeps_the_objects_own_fills_and_strokes() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Hi", 64.0);
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
for (cmd, p) in [
|
||||
("appearance.addFill", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 0, "color": "#ff0000"})),
|
||||
("appearance.addStroke", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 1, "color": "#0000ff", "weight": 5})),
|
||||
("appearance.addFill", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50})),
|
||||
// The red fill paints below the characters; the stroke and the green fill over them.
|
||||
("appearance.moveItem", json!({"from": "contents", "to": 1})),
|
||||
] {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
}
|
||||
let own = node(&s, t).appearance.items.clone();
|
||||
assert_eq!(own.len(), 3);
|
||||
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
assert!(g.appearance.items.is_empty(), "the group paints nothing of its own");
|
||||
let ch = g.children().unwrap();
|
||||
assert_eq!(ch.len(), 4, "a path below, the two glyphs, a path above");
|
||||
let (first, last) = (&ch[0], &ch[3]);
|
||||
assert_eq!(first.appearance.items, own[..1], "the item below the characters");
|
||||
assert_eq!(last.appearance.items, own[1..], "the items over them, in their order");
|
||||
for c in [first, last] {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }), "{:?}", c.kind);
|
||||
assert!(c.id != t && c.id != g.id, "a fresh id");
|
||||
}
|
||||
assert!(first.id != last.id);
|
||||
for c in &ch[1..3] {
|
||||
assert_eq!(c.appearance.fill_paint(), Paint::solid(Color::BLACK), "the characters keep their own paint");
|
||||
}
|
||||
// The whole-outline paths cover both glyphs.
|
||||
let glyphs = ch[1].geometric_bounds().unwrap().union(ch[2].geometric_bounds().unwrap());
|
||||
assert!(close(first.geometric_bounds().unwrap(), glyphs, 1e-6), "{:?} vs {glyphs:?}", first.geometric_bounds());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, t).appearance.items, own, "one undo step brings the type back");
|
||||
}
|
||||
|
||||
/// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image
|
||||
/// share its pixels.
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@ fn is_bad_params(r: Result<Value>) -> bool {
|
||||
}
|
||||
|
||||
/// Folder preferences, which a preferences file leaves out.
|
||||
const FOLDERS: [&str; 4] = ["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
|
||||
const FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// A preferences file holding `prefs`, as `prefs.export` writes one.
|
||||
fn file(prefs: Value) -> String {
|
||||
@@ -46,7 +46,9 @@ fn in_file(s: &Session) -> Value {
|
||||
fn exported_preferences_import_unchanged() {
|
||||
let mut s = changed();
|
||||
s.prefs.fonts_folder = "/fonts/here".into();
|
||||
s.prefs.scratch_primary = "/scratch/here".into();
|
||||
let r = s.execute("prefs.export", &json!({})).unwrap();
|
||||
assert!(!r["data"].as_str().unwrap().contains("/scratch/here"), "a scratch disk folder is not written");
|
||||
let text = r["data"].as_str().unwrap().to_string();
|
||||
let written: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!((written["format"].as_str(), written["version"].as_u64()), (Some(PREFS_FORMAT), Some(1)));
|
||||
@@ -71,6 +73,7 @@ fn exported_preferences_import_unchanged() {
|
||||
assert_eq!(t.doc().unwrap().doc.units, Unit::Millimeters, "the document's units stay as they are");
|
||||
assert_eq!(t.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
assert_eq!(t.prefs.fonts_folder, "", "folders are not carried over");
|
||||
assert_eq!(t.prefs.scratch_primary, "Startup", "nor is a scratch disk");
|
||||
|
||||
// Through a file, too.
|
||||
let path = std::env::temp_dir().join(format!("vc-prefs-{}.json", std::process::id())).to_string_lossy().to_string();
|
||||
@@ -96,6 +99,10 @@ fn a_hand_written_file_sets_what_it_names_and_leaves_the_rest() {
|
||||
assert_eq!(s.prefs.keyboard_increment, 3.0);
|
||||
assert_eq!(s.prefs.guide_color, "#abcdef");
|
||||
assert_eq!(s.prefs.history_states, 77, "a preference the file leaves out keeps its value");
|
||||
// A scratch disk choice names no folder, and is read.
|
||||
let choices = file(json!({"scratchPrimary": "None", "scratchSecondary": "Startup"}));
|
||||
assert_eq!(s.execute("prefs.import", &json!({"data": choices})).unwrap(), json!({"imported": 2}));
|
||||
assert_eq!((s.prefs.scratch_primary.as_str(), s.prefs.scratch_secondary.as_str()), ("None", "Startup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -119,6 +126,8 @@ fn a_bad_preferences_file_is_refused_and_nothing_changes() {
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "historyStates": 5000}))}), "`historyStates` must be a whole number between 5 and 1000"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "eyedropper": 5}))}), "`eyedropper` must be an object"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "fontsFolder": "/fonts"}))}), "`fontsFolder` names a folder"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "scratchPrimary": "/scratch"}))}), "`scratchPrimary` names a folder"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "scratchSecondary": " scratch "}))}), "`scratchSecondary` names a folder"),
|
||||
(json!({"data": too_large}), "larger than 1 MB"),
|
||||
(json!({"data": 5}), "`data` must be a string"),
|
||||
(json!({"path": "/a.json", "data": file(json!({"uiAccent": "blue"}))}), "give `path` or `data`"),
|
||||
|
||||
@@ -2137,8 +2137,9 @@ fn datasets_csv_reads_windows_1252_files() {
|
||||
}
|
||||
|
||||
/// A CSV that starts with a UTF-16 byte-order mark is read as UTF-16, and
|
||||
/// one that isn't valid UTF-16 is refused. A file with NUL bytes and no byte-order mark is refused.
|
||||
/// A text value holding NUL imports back from the `csv` that `dataset.export` returns.
|
||||
/// one that isn't valid UTF-16 is refused. A file with NUL bytes and no byte-order mark that isn't
|
||||
/// UTF-16 is refused. A text value holding NUL imports back from the `csv` that `dataset.export`
|
||||
/// returns.
|
||||
#[test]
|
||||
fn datasets_csv_reads_utf16_files() {
|
||||
let mut s = session();
|
||||
@@ -2160,7 +2161,7 @@ fn datasets_csv_reads_utf16_files() {
|
||||
assert!(e.contains("CSV is not UTF-16"), "{e}");
|
||||
let e = import(&mut s, &[b"\xff\xfe".as_slice(), "City\nAB,".as_bytes(), b"\x00\xd8"].concat()).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV is not UTF-16"), "{e}");
|
||||
let e = import(&mut s, &utf16(b"", "City\nx\n", true)).unwrap_err().to_string();
|
||||
let e = import(&mut s, b"City\nx\0\n").unwrap_err().to_string();
|
||||
assert!(e.contains("CSV holds NUL bytes"), "{e}");
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("José"), "a refused file changes nothing");
|
||||
s.execute("dataset.set", &json!({"name": "Data Set 1", "values": {"City": "a\u{0}b"}})).unwrap();
|
||||
@@ -2170,6 +2171,63 @@ fn datasets_csv_reads_utf16_files() {
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("a\u{0}b"));
|
||||
}
|
||||
|
||||
/// A CSV in UTF-16 without a byte-order mark, with a NUL byte in at least half of its units, is
|
||||
/// read in the byte order that reads a line break: in either order, with characters whose bytes
|
||||
/// read as a line break, a quote or NUL, and when its bytes are also valid UTF-8. Files with fewer
|
||||
/// NUL bytes, that read a line break in both orders, that aren't valid UTF-16 in it, that hold a
|
||||
/// NUL character or that have an odd length are refused.
|
||||
#[test]
|
||||
fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "City", "kind": "text"})).unwrap();
|
||||
let import = |s: &mut Session, bytes: &[u8]| s.execute("dataset.import", &json!({"dataBase64": vectorcraft_format::base64_encode(bytes)}));
|
||||
let value = |s: &Session, name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("City")).cloned();
|
||||
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
|
||||
let utf16 = |text: &str, le: bool| -> Vec<u8> { text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect() };
|
||||
import(&mut s, &utf16("City\r\nParis\r\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Paris"));
|
||||
import(&mut s, &utf16("City\nZoë 😀\n", false)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Zoë 😀"));
|
||||
// U+4E00's low byte is NUL, U+010A holds a `\n` byte and U+2200 a `"` byte; all three files
|
||||
// are valid UTF-8 too.
|
||||
import(&mut s, &utf16("City\n一\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一"));
|
||||
import(&mut s, &utf16("City\nĊ\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Ċ"));
|
||||
import(&mut s, &utf16("City\n∀∀∀\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("∀∀∀"));
|
||||
// A file with no line break has no data rows in either byte order.
|
||||
for bytes in [utf16("City", true), utf16("City", false), b"\0aa\0".to_vec()] {
|
||||
let e = import(&mut s, &bytes).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV has a header but no data rows"), "{e}");
|
||||
}
|
||||
import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目"));
|
||||
// Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks
|
||||
// give the byte order.
|
||||
import(&mut s, &utf16("City\n一一一一一一一一一一二\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一一一一一一一一一一二"));
|
||||
// Windows-1252 and UTF-8 text with stray NUL bytes (the first with `\n\0` units), UTF-16 with
|
||||
// fewer than half of its units holding a NUL byte, and, last, two files that read a line break
|
||||
// in both byte orders (U+0D00 reads as `\r` and U+0A00 as `\n` big-endian).
|
||||
let refused: [&[u8]; 9] = [
|
||||
b"Caf\xe9\n\0Lyon\n\0",
|
||||
b"Cit\0\n",
|
||||
b"City\nx\0\n",
|
||||
&utf16("City\n丁丁丁丁丁丁丁丁丁丁\n", true),
|
||||
&utf16("City\na\u{0}\n", true),
|
||||
&[utf16("City\n", true), vec![0x00, 0xD8], utf16("\n", true)].concat(),
|
||||
&[utf16("City\n", false), vec![0xDC, 0x00], utf16("\n", false)].concat(),
|
||||
&utf16("City\n\u{0D00}\u{0D00}\u{0D00}\n", true),
|
||||
&utf16("City\u{0A00}\nParis\n", true),
|
||||
];
|
||||
for bytes in refused {
|
||||
let e = import(&mut s, bytes).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV holds NUL bytes"), "{bytes:?}: {e}");
|
||||
}
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一一一一一一一一一一二"), "a refused file changes nothing");
|
||||
}
|
||||
|
||||
/// A data set of [`library`]: its name, and each variable's name with its paragraphs.
|
||||
type LibrarySet<'a> = (&'a str, &'a [(&'a str, &'a [&'a str])]);
|
||||
|
||||
|
||||
@@ -1164,6 +1164,12 @@ impl Session {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
|
||||
}
|
||||
|
||||
/// Does the active tool take `key`, pressed with `mods` held, ahead of the shortcuts bound to it
|
||||
/// (see `Tool::claims_key_with`)?
|
||||
pub fn tool_claims_key_with(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> bool {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key_with(cx, key, mods))
|
||||
}
|
||||
|
||||
/// Has the active tool started something that Escape ends while no button is pressed (see
|
||||
/// `Tool::ends_on_escape`)?
|
||||
pub fn tool_ends_on_escape(&mut self, view: ViewInfo) -> bool {
|
||||
|
||||
@@ -7488,3 +7488,33 @@ proptest! {
|
||||
prop_assert!(!notes.iter().any(|n| n.contains("art this can't read")), "the records aren't art: {:?}", notes);
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph in `.ai` editing data whose records read whole (version 7 specification, appendix A), of
|
||||
/// a table `rows` × `cols` (its label row and column included): a series group tagged by its
|
||||
/// column, and the axes on the plot's edges.
|
||||
fn live_graph_text(rows: &str, cols: &str) -> String {
|
||||
format!(
|
||||
"%_Gs\n%_10 90 110 10 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\
|
||||
%_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\
|
||||
%_{rows} {cols} 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\
|
||||
u\nu\n0 0 0 1 k\n20 10 m\n20 50 L\n40 50 L\n40 10 L\nf\nU\n%_2 1 0 0 Go\nu\n0 0 0 1 K\n10 90 m\n10 10 L\n110 10 L\nS\nU\nU\n%_GS\n"
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// A `.ai` graph that reads whole, then cut or damaged, or with a table of any size (zero, past
|
||||
/// the limits, past what memory holds), never panics, and its drawn art always comes in.
|
||||
#[test]
|
||||
fn ai_live_graph_never_panics(cut in 0usize..800, edits in prop::collection::vec((0usize..800, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '%', '_', 'G', 'c', 'C', 'o', 's', 'S', 'u', 'U'])), 0..12), rows in prop_oneof![Just(2u64), 0u64..20_000, Just(u64::MAX)], cols in prop_oneof![Just(2u64), 0u64..400, Just(4_000_000_000u64)]) {
|
||||
let text = mutate_text(&live_graph_text("2", "2"), cut, &edits);
|
||||
survive("mutated live .ai graph", || Some(vectorcraft_eps::layered_ai(&ai_private(&text), Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
let sized = live_graph_text(&rows.to_string(), &cols.to_string());
|
||||
let (doc, notes, read) = vectorcraft_eps::layered_ai(&ai_private(&sized), Document::new(4000.0, 200.0), vec![], false, true, 1.0);
|
||||
prop_assert!(read, "{:?}", notes);
|
||||
let mut paths = 0;
|
||||
doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
|
||||
prop_assert_eq!(paths, 2, "{:?}", notes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7582,9 +7582,10 @@ fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() {
|
||||
}
|
||||
|
||||
/// A link to a graphic style with Twist is kept where the reader reads Twist: on a curve, a curved
|
||||
/// compound path or a group with nothing stroked up to a quarter turn, and on a path with straight
|
||||
/// segments at any angle. A stroked curve, a group with a stroked member or a guide, or a curve,
|
||||
/// compound path or group twisted further, is written without the link and warns.
|
||||
/// compound path or a group with nothing stroked up to a quarter turn, and on a path or compound path
|
||||
/// with straight segments up to half a turn. A stroked curve, a group with a stroked member or a
|
||||
/// guide, a straight compound path with a guide member that reaches past its other members, or an
|
||||
/// object twisted further, is written without the link and warns.
|
||||
#[test]
|
||||
fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
// A document with the object `make` builds from its appearance, `ap` with Twist at `angle`,
|
||||
@@ -7619,6 +7620,17 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
};
|
||||
// A compound path of a square and a guide, a rectangle with corners `a` and `b`.
|
||||
let guided = |a: (f64, f64), b: (f64, f64)| {
|
||||
move |doc: &mut Document, ap: Appearance| {
|
||||
let outer = Node::path(doc.alloc_id(), square(), Appearance::default());
|
||||
let path = shapes::rectangle(Rect::new(a.0, a.1, b.0, b.1));
|
||||
let guide = Node::new(doc.alloc_id(), NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: true });
|
||||
let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(guide)], rule: FillRule::NonZero });
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
}
|
||||
};
|
||||
// A group of a filled square and `second`.
|
||||
let group = |second: fn(&mut Document) -> Node| {
|
||||
move |doc: &mut Document, ap: Appearance| {
|
||||
@@ -7649,6 +7661,8 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
("a square twisted three quarters of a turn back", twisted(-270.0, filled(), path(square())), false),
|
||||
("a straight compound path twisted half a turn", twisted(180.0, filled(), frame), true),
|
||||
("a straight compound path twisted a full turn", twisted(360.0, filled(), frame), false),
|
||||
("a straight compound path with a guide inside it", twisted(45.0, filled(), guided((30.0, 20.0), (70.0, 50.0))), true),
|
||||
("a straight compound path with a guide reaching past it", twisted(45.0, filled(), guided((30.0, 20.0), (120.0, 50.0))), false),
|
||||
("a curve twisted past a quarter turn", twisted(120.0, filled(), path(oval())), false),
|
||||
("a stroked curve", twisted(45.0, Appearance::basic(black_fill(), red.clone(), 2.0), path(oval())), false),
|
||||
("a curved compound path", twisted(-60.0, filled(), ring), true),
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose cli
|
||||
pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps";
|
||||
pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps";
|
||||
pub(super) const LIVE: &str = "blends, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps";
|
||||
pub(super) const GRAPHS: &str = "graphs are written as their art, without their data or settings, in the editing data for other apps";
|
||||
pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
|
||||
pub(super) const ITEM_TRANSPARENCY: &str =
|
||||
"the opacity and blending modes of single fills and strokes are left out of the editing data for other apps";
|
||||
@@ -622,6 +623,9 @@ impl<'a> Writer<'a> {
|
||||
if self.in_symbol {
|
||||
self.wrote_fixed |= symbols::redrawn(n);
|
||||
}
|
||||
if n.extras().graph.is_some() && !self.in_look {
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
// On the layers or in an opacity mask's art, outside art cut to a clipping object: with its
|
||||
// art style, when it has one.
|
||||
if !self.in_look
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
//!
|
||||
//! - Graphs (version 7 specification §17): `%_Gs`, the graph's data and settings on `%_` lines,
|
||||
//! its drawn art as ordinary groups, paths and type (each object followed by `%_… Go`), `%_GS`.
|
||||
//! The drawn art is read; the data and settings are left out, with a note.
|
||||
//! The drawn art is read as it is. Its group keeps the data and settings when they read whole
|
||||
//! ([`graph`]); otherwise they are left out, with a note.
|
||||
//!
|
||||
//! - Swatches: the palette (`%AI5_BeginPalette`), read by [`super::palette`], replaces the
|
||||
//! default swatches.
|
||||
@@ -133,6 +134,7 @@ mod art;
|
||||
mod containers;
|
||||
mod directives;
|
||||
mod document;
|
||||
mod graph;
|
||||
mod masks;
|
||||
mod on_path;
|
||||
mod ops;
|
||||
@@ -146,12 +148,13 @@ pub use document::{
|
||||
symbols, variables,
|
||||
};
|
||||
pub(crate) use document::{slot_frame, slot_of};
|
||||
use graph::Graph;
|
||||
pub(crate) use ops::{read_art_style, read_style_list, style_moves_with};
|
||||
#[cfg(test)]
|
||||
pub(crate) use raster::with_image_limits;
|
||||
pub(crate) use types::MAX_NODES;
|
||||
pub use types::PlacedPage;
|
||||
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
|
||||
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
|
||||
#[cfg(test)]
|
||||
pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
|
||||
pub(super) use types::{blend_mode, blend_name};
|
||||
@@ -670,7 +673,7 @@ struct Reader<'a> {
|
||||
brushes_kept: BrushesKept,
|
||||
/// Brush objects that weren't read.
|
||||
brushes_unread: usize,
|
||||
/// The graph being read (see [`Reader::graph_record`]).
|
||||
/// The graph being read (see [`graph`]).
|
||||
graph: Option<Graph>,
|
||||
/// The multi-layer mask being read (`Mb` … `MB`), which goes on past a layer's end.
|
||||
multi_mask: Option<masks::MultiMask>,
|
||||
|
||||
@@ -1165,7 +1165,9 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
pub(super) fn finish(mut self) -> Result<Structure, String> {
|
||||
if self.graph.take().is_some() {
|
||||
if self.graph.is_some() {
|
||||
self.graph_plain();
|
||||
self.graph = None;
|
||||
self.warn(super::types::GRAPH_UNENDED);
|
||||
}
|
||||
// Envelopes whose plugin never closed them keep their drawn art.
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
//! A graph's records (version 7 specification §17 and appendix A) and the live graph they give.
|
||||
//!
|
||||
//! A graph is `%_Gs`, its functional records on `%_` lines (`Gb`, `Gy`, `Gd`, any `Gj`, three
|
||||
//! `Ga` … `GA` axes, any `Gw`, `Gz`, `Gc` cells and `GC`), any `version Gt` … `GT` customizations, its
|
||||
//! drawn art in one group (objects followed by `%_… Go`), then `%_GS`. The drawn art is always
|
||||
//! read as it is. The group also keeps the graph's data and settings ([`GraphSpec`]) when every
|
||||
//! record has the shape and values appendix A gives and the model holds each setting, the table
|
||||
//! has data, the art draws the plot's left and bottom edges, and each series' group is tagged, is
|
||||
//! one of the graph's own groups and paints its paths with one fill and one stroke, which the
|
||||
//! graph keeps as the series' paint.
|
||||
//! Any other graph stays plain art, with a note.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{PathSeg, Point};
|
||||
use vectorcraft_doc::{Appearance, GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength};
|
||||
|
||||
use super::types::{GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, Kind};
|
||||
use super::{Affine, AppearanceItem, BlendMode, Node, NodeId, NodeKind, Paint, Reader, Rect, V, obj};
|
||||
|
||||
/// Most cells of a graph's table, its label row and column included.
|
||||
const MAX_CELLS: usize = 2_600_000;
|
||||
|
||||
/// Longest cell string (appendix A, `Gc`).
|
||||
const MAX_CELL_TEXT: usize = 255;
|
||||
|
||||
/// Most characters of an axis label's text before or after its numbers (appendix A, `Ga`, `GA`).
|
||||
const MAX_AFFIX: usize = 9;
|
||||
|
||||
/// Most tick marks per division a graph draws.
|
||||
const MAX_TICK_MARKS: usize = 20;
|
||||
|
||||
/// Most divisions of a value axis with set values.
|
||||
const MAX_DIVISIONS: f64 = 100.0;
|
||||
|
||||
/// How far an axis line may be from the plot's edge, in points.
|
||||
const EDGE: f64 = 0.01;
|
||||
|
||||
/// The axes in the order the records give them: bottom, left, right.
|
||||
const AXES: [i64; 3] = [1, 2, 4];
|
||||
|
||||
/// The `Go` targets appendix A lists.
|
||||
const TARGETS: [i64; 11] = [1, 2, 4, 5, 6, 8, 9, 10, 15, 20, 22];
|
||||
|
||||
/// The version of a graph's customizations (appendix A, `Gt`).
|
||||
const CUSTOM_VERSION: i64 = 2;
|
||||
|
||||
/// `Go`'s target for one series, including its legend.
|
||||
const SERIES: i64 = 2;
|
||||
|
||||
/// A graph being read (`%_Gs` … `%_GS`).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Graph {
|
||||
/// Between `Gt` and `GT`, where the graph's settings may use paint and type operators on `%_`
|
||||
/// lines too.
|
||||
pub(super) settings: bool,
|
||||
/// How many frames were open at its `Gs`: a layer that ends with fewer ends the graph.
|
||||
pub(super) frames: usize,
|
||||
/// How many objects the innermost frame held at `Gs`: the drawn art is the one after them.
|
||||
siblings: usize,
|
||||
/// The records read so far, while they can still give a live graph.
|
||||
live: Option<Box<Records>>,
|
||||
}
|
||||
|
||||
/// Where the records are, in the order appendix A gives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Bounds,
|
||||
Style,
|
||||
Dialog,
|
||||
/// Before the `Ga` of axis `n`.
|
||||
Axis(usize),
|
||||
/// Before the `GA` of axis `n`.
|
||||
AxisEnd(usize),
|
||||
Widths,
|
||||
Cells,
|
||||
/// After `GC`: customizations, then the drawn art.
|
||||
Done,
|
||||
/// Between `Gt` and `GT`.
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// One axis: `whichAxis beforeString Ga` and `useManualValues tickMarkType minimumValue maximumValue
|
||||
/// betweenValue smallTicksPerValue drawMarksBetweenLabels afterString GA`.
|
||||
#[derive(Debug, Default)]
|
||||
struct Axis {
|
||||
before: Vec<u8>,
|
||||
manual: bool,
|
||||
tick: TickLength,
|
||||
min: f64,
|
||||
max: f64,
|
||||
between: f64,
|
||||
small: usize,
|
||||
between_labels: bool,
|
||||
after: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A cell of the table (`Gc`).
|
||||
#[derive(Debug)]
|
||||
enum Cell {
|
||||
Num(f64),
|
||||
Text(Vec<u8>),
|
||||
}
|
||||
|
||||
/// The records of a graph read so far.
|
||||
#[derive(Debug, Default)]
|
||||
struct Records {
|
||||
stage: Stage,
|
||||
spec: GraphSpec,
|
||||
axes: [Axis; 3],
|
||||
/// `Gw`: the first column and how many widths.
|
||||
widths: Vec<(usize, usize)>,
|
||||
/// `Gz`: rows, columns, first data row and first data column.
|
||||
size: Option<[usize; 4]>,
|
||||
cells: Vec<Cell>,
|
||||
/// The groups `Go` tags as one series, and the column each names.
|
||||
series: Vec<(NodeId, usize)>,
|
||||
}
|
||||
|
||||
/// A whole number.
|
||||
fn int(v: &V) -> Option<i64> {
|
||||
v.num().filter(|x| x.is_finite() && x.fract() == 0.0 && x.abs() < 1e15).map(|x| x as i64)
|
||||
}
|
||||
|
||||
/// 0 or 1.
|
||||
fn flag(v: &V) -> Option<bool> {
|
||||
match int(v)? {
|
||||
0 => Some(false),
|
||||
1 => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(v: &V) -> Option<f64> {
|
||||
v.num().filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// A count from 0 to `max`.
|
||||
fn count(v: &V, max: usize) -> Option<usize> {
|
||||
usize::try_from(int(v)?).ok().filter(|n| *n <= max)
|
||||
}
|
||||
|
||||
/// `tickMarkType`: 13 none, 14 short, 15 the length of the plot.
|
||||
fn tick(v: &V) -> Option<TickLength> {
|
||||
match int(v)? {
|
||||
13 => Some(TickLength::None),
|
||||
14 => Some(TickLength::Short),
|
||||
15 => Some(TickLength::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `graphType`: 5 grouped column, 6 stacked column, 7 line, 10 area. Pie (8) and scatter (9) graphs
|
||||
/// keep settings the records don't give the model, and other values are not graph types.
|
||||
fn kind(v: &V) -> Option<GraphKind> {
|
||||
match int(v)? {
|
||||
5 => Some(GraphKind::Column),
|
||||
6 => Some(GraphKind::StackedColumn),
|
||||
7 => Some(GraphKind::Line),
|
||||
10 => Some(GraphKind::Area),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A percentage of the available width, 1 to 1000.
|
||||
fn percent(v: &V) -> Option<f64> {
|
||||
finite(v).filter(|p| (1.0..=1000.0).contains(p))
|
||||
}
|
||||
|
||||
/// The tick count and values of an axis: with set values (`useManualValues` 1), the divisions
|
||||
/// between its lowest and highest value, a whole number of `betweenValue` steps from the lowest up.
|
||||
fn values(a: &Axis) -> Option<(usize, Option<f64>, Option<f64>)> {
|
||||
if !a.manual {
|
||||
return Some((0, None, None));
|
||||
}
|
||||
let n = (a.max - a.min) / a.between;
|
||||
let whole = n.round();
|
||||
let exact = (n - whole).abs() <= 1e-6 * whole.max(1.0);
|
||||
(a.between > 0.0 && a.max > a.min && exact && (1.0..=MAX_DIVISIONS).contains(&whole)).then_some((whole as usize, Some(a.min), Some(a.max)))
|
||||
}
|
||||
|
||||
impl Records {
|
||||
/// Take the record `w` with its operands `vals`; `None` when its shape, values or place aren't
|
||||
/// those appendix A gives, or the model doesn't hold a value it sets. `to_doc` takes art space
|
||||
/// to the document.
|
||||
fn step(&mut self, w: &str, vals: &[V], to_doc: Affine) -> Option<()> {
|
||||
match (w, self.stage) {
|
||||
("Gb", Stage::Bounds) => {
|
||||
let [l, t, r, b] = vals else { return None };
|
||||
let [l, t, r, b] = [finite(l)?, finite(t)?, finite(r)?, finite(b)?];
|
||||
let plot = to_doc.transform_rect_bbox(Rect::new(l, b, r, t));
|
||||
let sound = r > l && t > b && plot.width() > 0.0 && plot.height() > 0.0 && plot.width().is_finite() && plot.height().is_finite();
|
||||
sound.then_some(())?;
|
||||
self.spec.rect = plot;
|
||||
self.stage = Stage::Style;
|
||||
}
|
||||
("Gy", Stage::Style) => {
|
||||
self.style(vals)?;
|
||||
self.stage = Stage::Dialog;
|
||||
}
|
||||
("Gd", Stage::Dialog) => {
|
||||
let [col_width, precision, series_order, both_axes, bar, group, edge] = vals else { return None };
|
||||
count(col_width, 20).filter(|w| *w >= 3)?;
|
||||
count(precision, 10)?;
|
||||
// `useBothAxis` copies one axis' values to the other, which the model doesn't do.
|
||||
(!flag(both_axes)?).then_some(())?;
|
||||
self.spec.first_column_in_front = flag(series_order)?;
|
||||
self.spec.column_width = percent(bar)?;
|
||||
self.spec.cluster_width = percent(group)?;
|
||||
self.spec.edge_to_edge = flag(edge)?;
|
||||
self.stage = Stage::Axis(0);
|
||||
}
|
||||
("Ga", Stage::Axis(n)) if n < AXES.len() => {
|
||||
let [axis, V::Str(before)] = vals else { return None };
|
||||
(int(axis)? == *AXES.get(n)?).then_some(())?;
|
||||
self.axes.get_mut(n)?.before = before.clone();
|
||||
self.stage = Stage::AxisEnd(n);
|
||||
}
|
||||
("GA", Stage::AxisEnd(n)) => {
|
||||
let [manual, ticks, min, max, between, small, between_labels, V::Str(after)] = vals else { return None };
|
||||
let a = self.axes.get_mut(n)?;
|
||||
(a.manual, a.tick) = (flag(manual)?, tick(ticks)?);
|
||||
(a.min, a.max, a.between) = (finite(min)?, finite(max)?, finite(between)?);
|
||||
a.small = count(small, 1000)?;
|
||||
a.between_labels = flag(between_labels)?;
|
||||
a.after = after.clone();
|
||||
self.stage = Stage::Axis(n + 1);
|
||||
}
|
||||
("Gw", Stage::Axis(3) | Stage::Widths) => {
|
||||
// `col width1 … widthX numParams Gw`: numParams counts `col` and the 1 to 14 widths.
|
||||
let (params, rest) = vals.split_last()?;
|
||||
let params = count(params, 15).filter(|p| *p >= 2 && *p == rest.len())?;
|
||||
let (col, widths) = rest.split_first()?;
|
||||
let col = count(col, MAX_CELLS)?;
|
||||
widths.iter().all(|w| int(w).is_some_and(|w| w == -1 || (3..=20).contains(&w))).then_some(())?;
|
||||
self.widths.push((col, params - 1));
|
||||
self.stage = Stage::Widths;
|
||||
}
|
||||
("Gz", Stage::Axis(3) | Stage::Widths) => {
|
||||
let [rows, cols, first_row, first_col] = vals else { return None };
|
||||
let (rows, cols) = (count(rows, MAX_CELLS)?, count(cols, MAX_CELLS)?);
|
||||
let (first_row, first_col) = (usize::from(flag(first_row)?), usize::from(flag(first_col)?));
|
||||
rows.checked_sub(first_row).filter(|c| (1..=MAX_GRAPH_CATEGORIES).contains(c))?;
|
||||
cols.checked_sub(first_col).filter(|s| (1..=MAX_GRAPH_SERIES).contains(s))?;
|
||||
rows.checked_mul(cols).filter(|n| *n <= MAX_CELLS)?;
|
||||
self.widths.iter().all(|(col, n)| col.checked_add(*n).is_some_and(|end| end <= cols)).then_some(())?;
|
||||
self.size = Some([rows, cols, first_row, first_col]);
|
||||
self.stage = Stage::Cells;
|
||||
}
|
||||
("Gc", Stage::Cells) => {
|
||||
// One to fifteen values, at most one of them a string.
|
||||
let strings = vals.iter().filter(|v| matches!(v, V::Str(_))).count();
|
||||
((1..=15).contains(&vals.len()) && strings <= 1).then_some(())?;
|
||||
let [rows, cols, ..] = self.size?;
|
||||
(self.cells.len() + vals.len() <= rows * cols).then_some(())?;
|
||||
for v in vals {
|
||||
self.cells.push(match v {
|
||||
V::Str(s) if s.len() <= MAX_CELL_TEXT => Cell::Text(s.clone()),
|
||||
_ => Cell::Num(finite(v)?),
|
||||
});
|
||||
}
|
||||
}
|
||||
("GC", Stage::Cells) if vals.is_empty() => self.stage = Stage::Done,
|
||||
// `version Gt`, of version 2. Customizations are edits made after the graph was built, which
|
||||
// the model can't replay: only an empty list keeps the graph live.
|
||||
("Gt", Stage::Done) => {
|
||||
let [version] = vals else { return None };
|
||||
(int(version)? == CUSTOM_VERSION).then_some(())?;
|
||||
self.stage = Stage::Custom;
|
||||
}
|
||||
("GT", Stage::Custom) if vals.is_empty() => self.stage = Stage::Done,
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// `graphType shadow dataPointOrder pieLegendStyle drawMarks drawLines drawLinesAsShapes
|
||||
/// drawLegendsAcrossTop lineShapeWidth whichAxis [piePercentage piePctDigits] Gy`.
|
||||
fn style(&mut self, vals: &[V]) -> Option<()> {
|
||||
let (base, pie) = match vals.len() {
|
||||
10 => (vals, None),
|
||||
12 => vals.split_at_checked(10).map(|(b, p)| (b, Some(p)))?,
|
||||
_ => return None,
|
||||
};
|
||||
let [ty, shadow, order, legend, marks, lines, shapes, across, width, axis] = base else { return None };
|
||||
self.spec.kind = kind(ty)?;
|
||||
self.spec.drop_shadow = flag(shadow)?;
|
||||
self.spec.first_row_in_front = flag(order)?;
|
||||
// 14: legends as in bar and line graphs; 15 (in wedges) and 16 (none) are pie styles.
|
||||
(int(legend)? == 14).then_some(())?;
|
||||
self.spec.legend = true;
|
||||
self.spec.mark_points = flag(marks)?;
|
||||
self.spec.connect_points = flag(lines)?;
|
||||
// Lines drawn as shapes have a width the model doesn't keep.
|
||||
(!flag(shapes)?).then_some(())?;
|
||||
self.spec.filled_lines = false;
|
||||
self.spec.legend_across_top = flag(across)?;
|
||||
finite(width).filter(|w| (0.0..=100.0).contains(w))?;
|
||||
// 44: the series are measured against the left axis; 45 (right) isn't kept per series.
|
||||
(int(axis)? == 44).then_some(())?;
|
||||
if let Some([percentage, digits]) = pie {
|
||||
// Pie percentages, which only pie graphs show; shown (1) isn't kept.
|
||||
(!flag(percentage)?).then_some(())?;
|
||||
int(digits).filter(|d| *d >= 0)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The graph's data and settings, its strings read as names in `names`; `None` when the table
|
||||
/// or an axis holds what the model can't.
|
||||
fn finish(&self, names: &mut obj::Names) -> Option<GraphSpec> {
|
||||
let [rows, cols, first_row, first_col] = self.size?;
|
||||
let mut spec = self.spec.clone();
|
||||
let text = |names: &mut obj::Names, b: &[u8], max: usize| {
|
||||
let t = names.quiet(b);
|
||||
(t.chars().count() <= max && !t.chars().any(char::is_control)).then(|| names.bytes(b))
|
||||
};
|
||||
// Axes: the bottom one is the category axis, the left one the value axis; the right one keeps
|
||||
// its settings for when it is shown.
|
||||
let [bottom, left, right] = &self.axes;
|
||||
(bottom.before.is_empty() && bottom.after.is_empty() && !bottom.manual).then_some(())?;
|
||||
(!left.between_labels && !right.between_labels).then_some(())?;
|
||||
(spec.category_tick_length, spec.category_tick_marks) = (bottom.tick, bottom.small.max(1));
|
||||
spec.ticks_between_labels = bottom.between_labels;
|
||||
(spec.tick_length, spec.tick_marks) = (left.tick, left.small.max(1));
|
||||
(spec.right_tick_length, spec.right_tick_marks) = (right.tick, right.small.max(1));
|
||||
(spec.category_tick_marks <= MAX_TICK_MARKS && spec.tick_marks <= MAX_TICK_MARKS && spec.right_tick_marks <= MAX_TICK_MARKS).then_some(())?;
|
||||
(spec.ticks, spec.axis_min, spec.axis_max) = values(left)?;
|
||||
(spec.right_ticks, spec.right_axis_min, spec.right_axis_max) = values(right)?;
|
||||
spec.prefix = text(names, &left.before, MAX_AFFIX)?;
|
||||
spec.suffix = text(names, &left.after, MAX_AFFIX)?;
|
||||
spec.right_prefix = text(names, &right.before, MAX_AFFIX)?;
|
||||
spec.right_suffix = text(names, &right.after, MAX_AFFIX)?;
|
||||
// The table, row by row; cells left out at its end are empty.
|
||||
let cell = |r: usize, c: usize| self.cells.get(r * cols + c);
|
||||
let label = |names: &mut obj::Names, r: usize, c: usize| match cell(r, c) {
|
||||
None => Some(String::new()),
|
||||
Some(Cell::Text(t)) => text(names, t, MAX_CELL_TEXT),
|
||||
Some(Cell::Num(_)) => None,
|
||||
};
|
||||
// The corner of a table with both label row and label column holds nothing.
|
||||
let corner_empty = match cell(0, 0) {
|
||||
None => true,
|
||||
Some(Cell::Text(t)) => t.is_empty(),
|
||||
Some(Cell::Num(_)) => false,
|
||||
};
|
||||
if first_row == 1 && first_col == 1 && !corner_empty {
|
||||
return None;
|
||||
}
|
||||
spec.series = if first_row == 1 { (first_col..cols).map(|c| label(names, 0, c)).collect::<Option<_>>()? } else { vec![] };
|
||||
spec.categories = if first_col == 1 { (first_row..rows).map(|r| label(names, r, 0)).collect::<Option<_>>()? } else { vec![] };
|
||||
let mut data = Vec::with_capacity(rows - first_row);
|
||||
for r in first_row..rows {
|
||||
let row = (first_col..cols)
|
||||
.map(|c| match cell(r, c) {
|
||||
None => Some(None),
|
||||
Some(Cell::Text(t)) if t.is_empty() => Some(None),
|
||||
Some(Cell::Text(_)) => None,
|
||||
Some(Cell::Num(v)) => Some(Some(*v)),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
data.push(row);
|
||||
}
|
||||
data.iter().flatten().any(Option::is_some).then_some(())?;
|
||||
spec.set_cells(data);
|
||||
Some(spec)
|
||||
}
|
||||
|
||||
/// The series index of each group `Go` tagged as one series, by id: every series once, its
|
||||
/// columns counted in the table or among the series alike. `None` when the tags are missing,
|
||||
/// repeated or out of range: the series' paint is read from its group.
|
||||
fn series_tags(&self) -> Option<HashMap<NodeId, u32>> {
|
||||
let [_, cols, _, first_col] = self.size?;
|
||||
let n = cols - first_col;
|
||||
let ids: HashSet<NodeId> = self.series.iter().map(|(id, _)| *id).collect();
|
||||
(ids.len() == self.series.len() && self.series.len() == n).then_some(())?;
|
||||
let indexes = |offset: usize| -> Option<HashMap<NodeId, u32>> {
|
||||
let mut seen = HashSet::new();
|
||||
let tags: HashMap<NodeId, u32> = self
|
||||
.series
|
||||
.iter()
|
||||
.map(|(id, col)| {
|
||||
let s = col.checked_sub(offset).filter(|s| *s < n && seen.insert(*s))?;
|
||||
Some((*id, u32::try_from(s).ok()?))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
Some(tags)
|
||||
};
|
||||
// With a label column, the columns read as the table's or as the series' are told apart by the
|
||||
// set they make; without one, they are the same.
|
||||
match (indexes(0), indexes(first_col)) {
|
||||
(Some(a), Some(b)) if a == b => Some(a),
|
||||
(Some(a), None) | (None, Some(a)) => Some(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `art` draws the plot `plot`'s left edge and its bottom edge (the document's y runs down)
|
||||
/// as straight stroked lines: the value axis and the category axis, drawn on the plot's edges.
|
||||
fn draws_axes(art: &Node, plot: Rect) -> bool {
|
||||
let near = |a: Point, b: Point| (a - b).hypot() <= EDGE;
|
||||
let on = |s: &PathSeg, a: Point, b: Point| match s {
|
||||
PathSeg::Line(l) => (near(l.p0, a) && near(l.p1, b)) || (near(l.p0, b) && near(l.p1, a)),
|
||||
_ => false,
|
||||
};
|
||||
let (top_left, bottom_left, bottom_right) = (Point::new(plot.x0, plot.y0), Point::new(plot.x0, plot.y1), Point::new(plot.x1, plot.y1));
|
||||
let (mut left, mut bottom) = (false, false);
|
||||
let mut stack = vec![art];
|
||||
while let Some(n) = stack.pop() {
|
||||
// Only shown objects draw the axes, and a path only with a stroke.
|
||||
if !n.visible || n.opacity <= 0.0 || (left && bottom) {
|
||||
continue;
|
||||
}
|
||||
let stroked = n.appearance.items.iter().any(|it| !it.is_fill() && it.visible() && it.opacity() > 0.0 && !it.paint().is_none());
|
||||
if let NodeKind::Path { path, .. } = &n.kind
|
||||
&& stroked
|
||||
{
|
||||
for s in path.to_bezpath().segments() {
|
||||
left |= on(&s, top_left, bottom_left);
|
||||
bottom |= on(&s, bottom_left, bottom_right);
|
||||
}
|
||||
}
|
||||
stack.extend(n.children().into_iter().flatten().map(|c| &**c));
|
||||
}
|
||||
left && bottom
|
||||
}
|
||||
|
||||
/// Give the groups among `n`'s children that `tags` holds their series index.
|
||||
fn tag_series(n: &mut Node, tags: &HashMap<NodeId, u32>) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if let Some(i) = tags.get(&c.id) {
|
||||
Arc::make_mut(c).extras_mut().series_index = Some(*i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every node `tags` holds is a group among `n`'s children: the graph draws its series
|
||||
/// groups again as its own children.
|
||||
fn tags_groups(n: &Node, tags: &HashMap<NodeId, u32>) -> bool {
|
||||
let found = n.children().into_iter().flatten().filter(|c| tags.contains_key(&c.id) && matches!(c.kind, NodeKind::Group { .. })).count();
|
||||
found == tags.len()
|
||||
}
|
||||
|
||||
/// The paint of the shown fill (`fill`) or stroke of `p`, with a stroke's weight; `None` when it
|
||||
/// has none. `Err` when it has more than one, or that fill or stroke has an opacity, a blend mode
|
||||
/// or effects of its own, which a series' paint doesn't hold.
|
||||
fn shown(p: &Node, fill: bool) -> Result<Option<(Paint, f64)>, ()> {
|
||||
let mut items = p.appearance.items.iter().filter(|it| it.is_fill() == fill && it.visible());
|
||||
let Some(item) = items.next() else { return Ok(None) };
|
||||
if items.next().is_some() || item.opacity() != 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
if item.paint().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let width = match item {
|
||||
AppearanceItem::Stroke(s) => s.width,
|
||||
AppearanceItem::Fill(_) => 0.0,
|
||||
};
|
||||
Ok(Some((item.paint().clone(), width)))
|
||||
}
|
||||
|
||||
/// Whether `n` is shown, fully opaque, in Normal mode, not isolated and without an opacity mask:
|
||||
/// the graph draws its series groups and their marks so.
|
||||
fn as_drawn(n: &Node) -> bool {
|
||||
n.visible && n.opacity == 1.0 && n.blend == BlendMode::Normal && !n.isolate && n.extras().mask.is_none()
|
||||
}
|
||||
|
||||
/// Each series' fill and stroke, read from its group (`tags`), which holds paths only, one at
|
||||
/// least: the paths with a fill share it, and every path has the same stroke or none. Only a line
|
||||
/// graph (`line`) has paths without a fill: its lines. `None` when they differ, or the group, its
|
||||
/// own appearance or a path is drawn otherwise than [`as_drawn`].
|
||||
fn series_paints(art: &Node, tags: &HashMap<NodeId, u32>, line: bool) -> Option<Vec<SeriesPaint>> {
|
||||
let mut paints = vec![SeriesPaint::default(); tags.len()];
|
||||
for group in art.children().into_iter().flatten() {
|
||||
let Some(slot) = tags.get(&group.id).and_then(|i| paints.get_mut(usize::try_from(*i).ok()?)) else { continue };
|
||||
(as_drawn(group) && *group.appearance == Appearance::default()).then_some(())?;
|
||||
let paths: Vec<&Arc<Node>> = group.children().into_iter().flatten().collect();
|
||||
(!paths.is_empty() && paths.iter().all(|p| matches!(p.kind, NodeKind::Path { .. }))).then_some(())?;
|
||||
let (mut fills, mut strokes) = (vec![], vec![]);
|
||||
for p in &paths {
|
||||
as_drawn(p).then_some(())?;
|
||||
fills.extend(shown(p, true).ok()?);
|
||||
strokes.push(shown(p, false).ok()?);
|
||||
}
|
||||
let fill = fills.first().map(|(paint, _)| paint.clone());
|
||||
fills.iter().all(|(paint, _)| Some(paint) == fill.as_ref()).then_some(())?;
|
||||
(line || fills.len() == paths.len()).then_some(())?;
|
||||
let stroke = strokes.first().cloned().flatten();
|
||||
strokes.iter().all(|s| *s == stroke).then_some(())?;
|
||||
slot.fill = fill;
|
||||
(slot.stroke, slot.stroke_width) = match stroke {
|
||||
Some((paint, width)) => (Some(paint), Some(width)),
|
||||
None => (Some(Paint::None), None),
|
||||
};
|
||||
}
|
||||
Some(paints)
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
/// A graph's records: `%_Gs` starts the graph on a layer, `GS` ends it, and its data and settings
|
||||
/// (the operators in [`GRAPH_OPS`], and any operator on a `%_` line between `Gt` and `GT`) are
|
||||
/// taken with their operands `vals`. Its drawn art, and anything else, is read as usual. `true`
|
||||
/// when `w` was one of them. `ended`: whether a group and a mesh had just ended before `w`, which
|
||||
/// a `Go` tag after them leaves so.
|
||||
pub(super) fn graph_record(&mut self, w: &str, vals: &[V], hidden: bool, ended: (bool, bool)) -> bool {
|
||||
let depth = self.frames.len();
|
||||
let Some(g) = self.graph.as_mut() else {
|
||||
// Outside a graph, its operators stay ones the reader doesn't know.
|
||||
if w == "Gs" && hidden && !self.frames.is_empty() {
|
||||
let siblings = self.frames.last().map_or(0, |f| f.children.len());
|
||||
// A graph is live only where its art is in the document's space, in a layer or a group.
|
||||
let placed = self.pattern.is_none()
|
||||
&& self.symbol.is_none()
|
||||
&& vals.is_empty()
|
||||
&& matches!(self.frames.last().map(|f| &f.kind), Some(Kind::Layer(_) | Kind::Group | Kind::Clip));
|
||||
self.graph = Some(Graph { settings: false, frames: depth, siblings, live: placed.then(Box::default) });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// The frame the graph started in closed: its art isn't where it was.
|
||||
if depth < g.frames {
|
||||
self.graph_plain();
|
||||
}
|
||||
let Some(g) = self.graph.as_mut() else { return false };
|
||||
let stage = g.live.as_ref().map(|r| r.stage);
|
||||
match w {
|
||||
// A graph doesn't start inside another: the one being read goes on.
|
||||
"Gs" => {
|
||||
self.graph_plain();
|
||||
self.warn(GRAPH_UNENDED);
|
||||
}
|
||||
"GS" => self.graph_end(vals.is_empty() && hidden),
|
||||
"Gt" | "GT" => {
|
||||
g.settings = w == "Gt";
|
||||
self.graph_step(w, vals, hidden);
|
||||
}
|
||||
"Go" => {
|
||||
self.graph_tag(vals, hidden);
|
||||
(self.group_ended, self.mesh_ended) = ended;
|
||||
}
|
||||
_ if GRAPH_OPS.contains(&w) => self.graph_step(w, vals, hidden),
|
||||
_ if hidden && g.settings => self.graph_plain(),
|
||||
_ => {
|
||||
// Before its table ends, a graph has only its records.
|
||||
if !matches!(stage, None | Some(Stage::Done)) {
|
||||
self.graph_plain();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The graph being read can't be live: its art stays plain, with a note.
|
||||
pub(super) fn graph_plain(&mut self) {
|
||||
if let Some(g) = self.graph.as_mut() {
|
||||
g.live = None;
|
||||
}
|
||||
self.warn(GRAPH_PLAIN);
|
||||
}
|
||||
|
||||
/// Take a functional record or customization on a `%_` line.
|
||||
fn graph_step(&mut self, w: &str, vals: &[V], hidden: bool) {
|
||||
if self.graph.as_ref().is_none_or(|g| g.live.is_none()) {
|
||||
return;
|
||||
}
|
||||
let to_doc = if w == "Gb" { self.space() } else { Affine::IDENTITY };
|
||||
let ok = hidden && self.graph.as_mut().and_then(|g| g.live.as_mut()).and_then(|r| r.step(w, vals, to_doc)).is_some();
|
||||
if !ok {
|
||||
self.graph_plain();
|
||||
}
|
||||
}
|
||||
|
||||
/// `target column row whichAxis Go` after an object of the drawn art: a group tagged as one
|
||||
/// series is kept with the column it names.
|
||||
fn graph_tag(&mut self, vals: &[V], hidden: bool) {
|
||||
let depth = self.frames.len();
|
||||
let last = self
|
||||
.after_object
|
||||
.then(|| self.frames.last().and_then(|f| f.children.last()))
|
||||
.flatten()
|
||||
.map(|n| (n.id, matches!(n.kind, NodeKind::Group { .. })));
|
||||
let Some(g) = self.graph.as_mut() else { return };
|
||||
let inside = depth > g.frames;
|
||||
let Some(r) = g.live.as_mut() else { return };
|
||||
let tag = match vals {
|
||||
[target, column, row, axis] if hidden && r.stage == Stage::Done => {
|
||||
let (row, axis) = (int(row), int(axis));
|
||||
// A series is the whole column, measured on the left axis (2) or with no axis given (0):
|
||||
// the model keeps no series on a row or on the right axis.
|
||||
let whole_column = row == Some(0) && matches!(axis, Some(0 | 2));
|
||||
int(target).filter(|t| TARGETS.contains(t) && row.is_some() && axis.is_some()).zip(int(column)).map(|(t, c)| (t, c, whole_column))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let ok = match (tag, last) {
|
||||
(Some((SERIES, column, true)), Some((id, true))) if inside => usize::try_from(column).ok().map(|c| r.series.push((id, c))).is_some(),
|
||||
(Some((SERIES, ..)), _) => false,
|
||||
(Some(_), Some(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !ok {
|
||||
self.graph_plain();
|
||||
}
|
||||
}
|
||||
|
||||
/// `GS`: the graph ends. Its drawn art is the one object its frame gained, a group; it keeps the
|
||||
/// graph's data and settings when they read whole (`whole`) and the art is registered on its plot.
|
||||
fn graph_end(&mut self, whole: bool) {
|
||||
let Some(g) = self.graph.take() else { return };
|
||||
let depth = self.frames.len();
|
||||
let live = g.live.filter(|r| whole && r.stage == Stage::Done && depth == g.frames);
|
||||
let spec = live.as_ref().and_then(|r| r.finish(&mut self.names).zip(r.series_tags()));
|
||||
let attached = spec.is_some_and(|(mut spec, tags)| {
|
||||
let Some(art) = self.frames.last_mut().filter(|f| f.children.len() == g.siblings + 1).and_then(|f| f.children.last_mut()) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(art.kind, NodeKind::Group { clip: false, .. }) || !tags_groups(art, &tags) || !draws_axes(art, spec.rect) {
|
||||
return false;
|
||||
}
|
||||
let Some(placed) = art.geometric_bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0) else { return false };
|
||||
let r = spec.rect;
|
||||
if r.x0 < placed.x0 - EDGE || r.y0 < placed.y0 - EDGE || r.x1 > placed.x1 + EDGE || r.y1 > placed.y1 + EDGE {
|
||||
return false;
|
||||
}
|
||||
let Some(paints) = series_paints(art, &tags, spec.kind == GraphKind::Line) else { return false };
|
||||
spec.series_paints = paints;
|
||||
spec.placed = Some(placed);
|
||||
tag_series(art, &tags);
|
||||
art.extras_mut().graph = Some(Box::new(spec));
|
||||
true
|
||||
});
|
||||
if !attached {
|
||||
self.warn(GRAPH_PLAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// A graph's art: one series group (id 2) holding one mark painted `ap`, the group changed by
|
||||
/// `group`.
|
||||
fn art(ap: Appearance, group: impl Fn(&mut Node)) -> Node {
|
||||
let mark = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap));
|
||||
let mut series = Node::group(NodeId(2), vec![mark]);
|
||||
group(&mut series);
|
||||
Node::group(NodeId(1), vec![Arc::new(series)])
|
||||
}
|
||||
|
||||
/// A series whose mark has two fills has no one paint, so the graph can't draw it again.
|
||||
#[test]
|
||||
fn a_series_mark_with_two_fills_has_no_series_paint() {
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let one = Appearance::basic(red.clone(), Paint::None, 0.0);
|
||||
let mut two = one.clone();
|
||||
two.items.extend(one.items.iter().find(|i| i.is_fill()).cloned());
|
||||
let tags = HashMap::from([(NodeId(2), 0)]);
|
||||
let kept = series_paints(&art(one, |_| {}), &tags, false).expect("one fill");
|
||||
assert_eq!((kept[0].fill.as_ref(), kept[0].stroke.as_ref()), (Some(&red), Some(&Paint::None)));
|
||||
assert!(series_paints(&art(two, |_| {}), &tags, false).is_none());
|
||||
}
|
||||
|
||||
/// A series group that is hidden, see-through or in another blending mode is not drawn again so.
|
||||
#[test]
|
||||
fn a_series_group_drawn_otherwise_has_no_series_paint() {
|
||||
let ap = || Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let tags = HashMap::from([(NodeId(2), 0)]);
|
||||
assert!(series_paints(&art(ap(), |g| g.visible = false), &tags, false).is_none(), "hidden");
|
||||
assert!(series_paints(&art(ap(), |g| g.opacity = 0.5), &tags, false).is_none(), "50% opaque");
|
||||
assert!(series_paints(&art(ap(), |g| g.blend = BlendMode::Multiply), &tags, false).is_none(), "Multiply");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
use super::containers::close;
|
||||
use super::document::slot_of;
|
||||
use super::types::{
|
||||
BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, MAX_TAG_TEXT, MAX_TAGS, Place, URL_TAG,
|
||||
line_width,
|
||||
};
|
||||
use super::types::{BLENDS, FLIP, Frame, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, MAX_TAG_TEXT, MAX_TAGS, Place, URL_TAG, line_width};
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap,
|
||||
HashSet, Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, HashMap, HashSet,
|
||||
Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
|
||||
};
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
@@ -69,6 +66,7 @@ impl<'a> Reader<'a> {
|
||||
/// `LB`: a graph started in this layer that hasn't ended doesn't go on past it; the layer ends.
|
||||
fn layer_end(&mut self) -> Result<(), String> {
|
||||
if self.graph.as_ref().is_some_and(|g| g.frames >= self.frames.len()) {
|
||||
self.graph_plain();
|
||||
self.graph = None;
|
||||
self.warn(GRAPH_UNENDED);
|
||||
}
|
||||
@@ -158,7 +156,7 @@ impl<'a> Reader<'a> {
|
||||
let mesh_ended = std::mem::take(&mut self.mesh_ended);
|
||||
let vals = self.operands();
|
||||
let vals = self.take_head(vals);
|
||||
if self.graph_record(w, hidden) {
|
||||
if self.graph_record(w, &vals, hidden, (group_ended, mesh_ended)) {
|
||||
return Ok(());
|
||||
}
|
||||
// Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so
|
||||
@@ -428,33 +426,6 @@ impl<'a> Reader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A graph's records (version 7 specification §17 and appendix A): `%_Gs` starts the graph on
|
||||
/// a layer, `GS` ends it, and its data and settings (the operators in [`GRAPH_OPS`], and any
|
||||
/// operator on a `%_` line between `Gt` and `GT`) are left out with their operands. Its drawn
|
||||
/// art, and anything else, is read as usual. `true` when `w` was one of them.
|
||||
fn graph_record(&mut self, w: &str, hidden: bool) -> bool {
|
||||
let Some(g) = self.graph.as_mut() else {
|
||||
// Outside a graph, its operators stay ones the reader doesn't know.
|
||||
if w == "Gs" && hidden && !self.frames.is_empty() {
|
||||
self.graph = Some(Graph { settings: false, frames: self.frames.len() });
|
||||
self.warn(GRAPH_PLAIN);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
match w {
|
||||
// A graph doesn't start inside another: the one being read goes on.
|
||||
"Gs" => self.warn(GRAPH_UNENDED),
|
||||
"GS" => self.graph = None,
|
||||
"Gt" => g.settings = true,
|
||||
"GT" => g.settings = false,
|
||||
_ if GRAPH_OPS.contains(&w) => {}
|
||||
_ if hidden && g.settings => {}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `… Lb`: a layer's options, `visible preview unlocked printing dimmed`, then the colour index
|
||||
/// and its RGB. The colour index is at 5 with fewer than ten operands, 6 with ten to twelve
|
||||
/// (`masks` before it) and 7 with thirteen or more (one more flag before it). Four places after
|
||||
@@ -588,9 +559,9 @@ impl<'a> Reader<'a> {
|
||||
return;
|
||||
}
|
||||
self.image_tail = false;
|
||||
// An object's tags (`XT`) follow it one after another, and an image's follow the `XH` that
|
||||
// ends its data.
|
||||
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH") {
|
||||
// An object's tags (`XT`, and a graph's `Go`) follow it one after another, and an image's
|
||||
// follow the `XH` that ends its data.
|
||||
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go") {
|
||||
return;
|
||||
}
|
||||
self.after_object = false;
|
||||
@@ -1105,6 +1076,15 @@ fn no_guides(n: &Node, depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH && !n.is_guide() && n.children().into_iter().flatten().all(|c| no_guides(c, depth + 1))
|
||||
}
|
||||
|
||||
/// Is `n` a compound path with a guide member whose bounds reach past the bounds of its members that
|
||||
/// aren't guides, or with guide members only? Any other object counts as no.
|
||||
fn guides_reach_past(n: &Node) -> bool {
|
||||
let NodeKind::Compound { children, .. } = &n.kind else { return false };
|
||||
let bounds =
|
||||
|guides: bool| children.iter().filter(|c| c.is_guide() == guides).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds()));
|
||||
bounds(true).is_some_and(|g| bounds(false).is_none_or(|o| o.union(g) != o))
|
||||
}
|
||||
|
||||
/// The largest Twist angle, in degrees either way, read as a live effect on an object that isn't a
|
||||
/// path or compound path with straight segments: VectorCraft's own limit. An object with a larger
|
||||
/// Twist keeps the file's drawn look.
|
||||
@@ -1118,7 +1098,8 @@ const MAX_STRAIGHT_TWIST: f64 = 180.0;
|
||||
/// Is `object` one that style `st`, whose effect moves the outline, is read on? It and everything
|
||||
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only when nothing is
|
||||
/// stroked and its angle is a number: up to [`MAX_STRAIGHT_TWIST`] degrees either way on a path or
|
||||
/// compound path with straight segments, or up to [`MAX_TWIST`] degrees either way on another object
|
||||
/// compound path with straight segments (only at 0° when its guide members reach past its other
|
||||
/// members, see [`guides_reach_past`]), or up to [`MAX_TWIST`] degrees either way on another object
|
||||
/// with no guide in it. Transform that scales, turns or reflects is read only when nothing is stroked
|
||||
/// (its reference point lies on the bounds without strokes).
|
||||
fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
@@ -1131,7 +1112,12 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
&& st.effects.iter().all(|e| match e.id.as_str() {
|
||||
"distort.twist" => {
|
||||
let within = |limit: f64| e.params["angle"].as_f64().is_some_and(|a| a.abs() <= limit);
|
||||
!stroked && if straight(object) { within(MAX_STRAIGHT_TWIST) } else { no_guides(object, 0) && within(MAX_TWIST) }
|
||||
!stroked
|
||||
&& if straight(object) {
|
||||
within(MAX_STRAIGHT_TWIST) && (e.params["angle"].as_f64() == Some(0.0) || !guides_reach_past(object))
|
||||
} else {
|
||||
no_guides(object, 0) && within(MAX_TWIST)
|
||||
}
|
||||
}
|
||||
"distort.transform" => {
|
||||
let p = &e.params;
|
||||
|
||||
@@ -436,8 +436,8 @@ pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content)
|
||||
/// The name of the group a `/ForeignObject`'s art comes in as.
|
||||
pub const NON_NATIVE_ART: &str = "Non-native art";
|
||||
|
||||
/// The note for a file with a graph (`%_Gs` … `%_GS`): its drawn art comes in, its data and
|
||||
/// settings don't.
|
||||
/// The note for a file with a graph (`%_Gs` … `%_GS`) whose data and settings can't be kept: its
|
||||
/// drawn art comes in as plain art.
|
||||
pub const GRAPH_PLAIN: &str = "graphs come in as plain art, without their data or settings";
|
||||
|
||||
/// The note for a graph that starts inside another or doesn't end before its layer does: the art
|
||||
@@ -445,18 +445,8 @@ pub const GRAPH_PLAIN: &str = "graphs come in as plain art, without their data o
|
||||
pub const GRAPH_UNENDED: &str = "a graph's records don't end where they should: its art was read as it is";
|
||||
|
||||
/// A graph's records (the version 7 specification's chapter 17 and appendix A): two-letter
|
||||
/// operators starting with `G`, read and left out with their operands.
|
||||
/// operators starting with `G`, read with their operands.
|
||||
pub(super) const GRAPH_OPS: &[&str] = &[
|
||||
"G1", "G+", "Ga", "GA", "Gb", "Gc", "GC", "Gd", "GD", "Ge", "Gf", "Gg", "Gi", "Gj", "Gl", "Gm", "Go", "Gp", "Gr", "Gs", "GS", "Gt", "GT", "Gv",
|
||||
"Gw", "GX", "Gx", "Gy", "Gz",
|
||||
];
|
||||
|
||||
/// A graph being read (`%_Gs` … `%_GS`).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Graph {
|
||||
/// Between `Gt` and `GT`, where the graph's settings may use paint and type operators on `%_`
|
||||
/// lines too.
|
||||
pub(super) settings: bool,
|
||||
/// How many frames were open at its `Gs`: a layer that ends with fewer ends the graph.
|
||||
pub(super) frames: usize,
|
||||
}
|
||||
|
||||
@@ -7296,7 +7296,11 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
|
||||
("a twisted group", read(&twist(90.0, None), &distort_group()), 2),
|
||||
("a group with a hidden member", read(&twist(45.0, None), &hidden_member), 2),
|
||||
("a curve twisted a quarter turn back", read(&twist(-90.0, BLUE_FILL), &curved), 0),
|
||||
("a straight compound path with a guide, as before", read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(20.0, 20.0, 60.0, 50.0))), 2),
|
||||
(
|
||||
"a straight compound path with a guide inside it, as before",
|
||||
read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(0.0, 0.0, 60.0, 50.0))),
|
||||
2,
|
||||
),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
@@ -7363,6 +7367,48 @@ fn twist_is_read_on_straight_paths_and_compound_paths_up_to_half_a_turn() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twist_on_a_straight_compound_path_keeps_the_drawn_look_where_a_guide_reaches_past_its_other_members() {
|
||||
// GUARDS: a straight compound path whose guide members reach past its other members was read
|
||||
// with a live Twist measured from bounds that take in those guides, which draws differently from
|
||||
// the art saved with it.
|
||||
// The object, `hidden` painted with its style, under its drawn look.
|
||||
let object = |hidden: &str| {
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
|
||||
};
|
||||
// A compound path of a square and the guide from `from` to `to`.
|
||||
let with_guide = |from: (f64, f64), to: (f64, f64)| {
|
||||
object(&format!("*u\n{}f\n{} {} m\n{} {} L\n(N) *\n*U\n", rect_path(20.0, 20.0, 60.0, 50.0), from.0, from.1, to.0, to.1))
|
||||
};
|
||||
let twist = |angle: f64| distort_styles("Adobe Twirl", &format!("{angle} /Real (angle) ,\n"), BLUE_FILL);
|
||||
let read = |angle: f64, art_: &str| read_ok(&format!("{}{}", twist(angle), layer(art_)));
|
||||
for (what, r) in [
|
||||
("a guide inside the square", read(45.0, &with_guide((25.0, 25.0), (35.0, 35.0)))),
|
||||
("a guide on the square's corners", read(180.0, &with_guide((20.0, 20.0), (60.0, 50.0)))),
|
||||
("a guide inside the square, half a turn back", read(-180.0, &with_guide((30.0, 40.0), (50.0, 30.0)))),
|
||||
("a guide outside the square, not turned", read(0.0, &with_guide((5.0, 0.0), (15.0, 10.0)))),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert_eq!(a[0].children().map_or(0, |c| c.len()), 2, "{what}: the object, not its look: {a:#?}");
|
||||
let [e] = a[0].appearance.effects.as_slice() else { panic!("{what}: {:?}", a[0].appearance) };
|
||||
assert_eq!(e.id, "distort.twist", "{what}");
|
||||
}
|
||||
for (what, r) in [
|
||||
("a guide outside the square", read(45.0, &with_guide((5.0, 0.0), (15.0, 10.0)))),
|
||||
("a guide reaching past the square's right side", read(45.0, &with_guide((30.0, 30.0), (70.0, 40.0)))),
|
||||
("a guide reaching past the square's top, half a turn", read(180.0, &with_guide((30.0, 30.0), (40.0, 51.0)))),
|
||||
("a guide around the square, a quarter turn back", read(-90.0, &with_guide((0.0, 0.0), (80.0, 70.0)))),
|
||||
("a compound path of guides only", read(45.0, &object("*u\n25 25 m\n35 35 L\n(N) *\n40 25 m\n50 35 L\n(N) *\n*U\n"))),
|
||||
] {
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert!(a[0].appearance.effects.is_empty(), "{what}: the drawn look: {a:#?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
|
||||
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
|
||||
@@ -10547,6 +10593,7 @@ fn type_with_an_art_brush_keeps_its_drawn_look() {
|
||||
}
|
||||
|
||||
use read::{GRAPH_PLAIN, GRAPH_UNENDED};
|
||||
use vectorcraft_doc::{GraphKind, TickLength};
|
||||
|
||||
/// Editing data in the legacy format (version 7 specification, chapter 2): `%AI5_FileFormat 2.0`,
|
||||
/// the art size and the template box in the header, then `body`.
|
||||
@@ -10688,6 +10735,223 @@ fn a_graphs_records_take_their_operands() {
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN]);
|
||||
}
|
||||
|
||||
/// A graph's style record (`Gy`) for a grouped column graph: no shadow, legends as in column
|
||||
/// graphs, lines without marks, measured against the left axis.
|
||||
const LIVE_STYLE: &str = "%_5 0 0 14 0 1 0 0 6 44 Gy\n";
|
||||
|
||||
/// A graph's dialog record (`Gd`): columns 90% wide in clusters 80% wide.
|
||||
const LIVE_DIALOG: &str = "%_10 2 0 0 90 80 0 Gd\n";
|
||||
|
||||
/// A graph's axes, bottom, left and right: the bottom one with no tick marks, between its labels;
|
||||
/// the left one labeled `$` … ` units`, with two tick marks a division.
|
||||
const LIVE_AXES: &str = "%_1 () Ga\n%_0 13 0 0 0 0 1 () GA\n%_2 ($) Ga\n%_0 14 0 0 0 2 0 ( units) GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n";
|
||||
|
||||
/// A graph's table: series East and West, categories Q1 to Q3, and an empty cell (West in Q2).
|
||||
const LIVE_TABLE: &str = "%_4 3 1 1 Gz\n%_() Gc (East) Gc (West) Gc\n%_(Q1) Gc 5 3 (Q2) Gc 7 () Gc (Q3) Gc 4 6 Gc GC\n";
|
||||
|
||||
/// A column of a graph's series at `x`, tagged as one data bar.
|
||||
fn series_bar(x: f64) -> String {
|
||||
format!("0 0 0 1 k\n{}f\n%_4 1 0 0 Go\n", rect_path(x, 100.0, x + 20.0, 150.0))
|
||||
}
|
||||
|
||||
/// The first series' mark in [`live_art`].
|
||||
fn series_mark() -> String {
|
||||
format!("0 0 0 1 k\n{}f\n", rect_path(110.0, 100.0, 130.0, 150.0))
|
||||
}
|
||||
|
||||
/// [`series_mark`] filled and stroked, then an outline stroked alike with no fill.
|
||||
fn series_mark_and_outline() -> String {
|
||||
format!("0 0 0 1 k\n0 0 0 1 K\n{}B\n{}S\n", rect_path(110.0, 100.0, 130.0, 150.0), rect_path(112.0, 100.0, 118.0, 120.0))
|
||||
}
|
||||
|
||||
/// The drawn art of the graph [`live_graph`] gives: two series groups, each tagged as one series by
|
||||
/// its table column, and the axes on the plot's left and bottom edges.
|
||||
fn live_art() -> String {
|
||||
format!(
|
||||
"u\n{}U\n%_2 1 0 0 Go\nu\n{}U\n%_2 2 0 0 Go\nu\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\n%_9 0 0 2 Go\n",
|
||||
series_bar(110.0),
|
||||
series_bar(140.0)
|
||||
)
|
||||
}
|
||||
|
||||
/// A graph whose records read whole, written from the version 7 specification's appendix A, on the
|
||||
/// plot 100 100 300 200 (art space).
|
||||
fn live_graph() -> String {
|
||||
format!("%_Gs\n%_100 200 300 100 Gb\n{LIVE_STYLE}{LIVE_DIALOG}{LIVE_AXES}{LIVE_TABLE}u\n{}U\n%_GS\n", live_art())
|
||||
}
|
||||
|
||||
/// The graphs in `doc`'s art, and the series groups tagged in them.
|
||||
fn live_graphs(doc: &Document) -> (Vec<Node>, usize) {
|
||||
let (mut graphs, mut tagged) = (vec![], 0);
|
||||
doc.walk(|n| {
|
||||
if n.extras().graph.is_some() {
|
||||
graphs.push(n.clone());
|
||||
}
|
||||
tagged += usize::from(n.extras().series_index.is_some());
|
||||
});
|
||||
(graphs, tagged)
|
||||
}
|
||||
|
||||
fn same_rect(a: Rect, b: Rect) -> bool {
|
||||
(a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// A graph whose records read whole keeps its data and settings on its group, which holds its
|
||||
/// drawn art as it is, each series group tagged with its index.
|
||||
#[test]
|
||||
fn a_graph_whose_records_read_whole_keeps_its_data_and_settings() {
|
||||
let r = read(stream7(&layer7("Graph", &live_graph())).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1);
|
||||
let g = a[0].extras().graph.clone().expect("a live graph");
|
||||
assert_eq!(g.kind, GraphKind::Column);
|
||||
assert_eq!(g.series, ["East", "West"]);
|
||||
assert_eq!(g.categories, ["Q1", "Q2", "Q3"]);
|
||||
assert_eq!(g.cells(), [[Some(5.0), Some(3.0)], [Some(7.0), None], [Some(4.0), Some(6.0)]]);
|
||||
assert_eq!((g.column_width, g.cluster_width), (90.0, 80.0));
|
||||
assert_eq!((g.prefix.as_str(), g.suffix.as_str(), g.tick_length, g.tick_marks), ("$", " units", TickLength::Short, 2));
|
||||
assert_eq!((g.category_tick_length, g.ticks_between_labels), (TickLength::None, true));
|
||||
assert_eq!((g.ticks, g.axis_min, g.axis_max), (0, None, None));
|
||||
assert!(g.legend && !g.legend_across_top && !g.mark_points && g.connect_points && !g.drop_shadow && !g.edge_to_edge, "{g:?}");
|
||||
// The drawn art: two series groups and the axes, as written.
|
||||
let kids = a[0].children().unwrap();
|
||||
assert_eq!(kids.len(), 3);
|
||||
let index: Vec<_> = kids.iter().map(|k| k.extras().series_index).collect();
|
||||
assert_eq!(index, [Some(0), Some(1), None]);
|
||||
assert!(kids[..2].iter().all(|k| k.children().is_some_and(|c| c.len() == 1)));
|
||||
// Each series keeps its marks' fill, and no stroke, for when the graph is drawn again.
|
||||
assert_eq!(g.series_paints.len(), 2);
|
||||
let fill = kids[0].children().unwrap()[0].appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint().clone());
|
||||
assert!(g.series_paints.iter().all(|p| p.fill == fill && p.stroke.as_ref().is_some_and(|s| s.is_none())), "{:?}", g.series_paints);
|
||||
let axes = kids[2].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!(same_rect(g.rect, axes), "the plot is where the axes are: {:?} {axes:?}", g.rect);
|
||||
assert_eq!(g.placed, a[0].geometric_bounds());
|
||||
}
|
||||
|
||||
/// The graph types the records give: 5 grouped column, 6 stacked column, 7 line, 10 area; and the
|
||||
/// style and dialog settings, each kept as the graph's.
|
||||
#[test]
|
||||
fn a_graphs_type_and_settings_come_from_its_records() {
|
||||
let live = |style: &str, dialog: &str, axes: &str| {
|
||||
let text = live_graph().replace(LIVE_STYLE, style).replace(LIVE_DIALOG, dialog).replace(LIVE_AXES, axes);
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{style}{dialog}: {:?}", r.warnings);
|
||||
art(&r.doc)[0].extras().graph.clone().expect("a live graph")
|
||||
};
|
||||
for (ty, kind) in [(5, GraphKind::Column), (6, GraphKind::StackedColumn), (7, GraphKind::Line), (10, GraphKind::Area)] {
|
||||
let g = live(&format!("%_{ty} 0 0 14 0 1 0 0 6 44 Gy\n"), LIVE_DIALOG, LIVE_AXES);
|
||||
assert_eq!(g.kind, kind, "{ty}");
|
||||
}
|
||||
let axes = LIVE_AXES.replace("%_0 14 0 0 0 2 0 ( units) GA", "%_1 15 0 100 25 0 0 () GA");
|
||||
let g = live("%_7 1 1 14 1 0 0 1 6 44 0 2 Gy\n", "%_10 2 1 0 120 50 1 Gd\n", &axes);
|
||||
assert!(g.drop_shadow && g.first_row_in_front && g.mark_points && !g.connect_points && g.legend_across_top, "{g:?}");
|
||||
assert!(g.first_column_in_front && g.edge_to_edge, "{g:?}");
|
||||
assert_eq!((g.column_width, g.cluster_width), (120.0, 50.0));
|
||||
assert_eq!((g.ticks, g.axis_min, g.axis_max, g.tick_length, g.tick_marks), (4, Some(0.0), Some(100.0), TickLength::Full, 1));
|
||||
// A line graph's series draws its line unfilled beside its filled marks.
|
||||
let text = live_graph().replace(LIVE_STYLE, "%_7 0 0 14 0 1 0 0 6 44 Gy\n").replacen(&series_mark(), &series_mark_and_outline(), 1);
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(art(&r.doc)[0].extras().graph.is_some());
|
||||
// An empty list of customizations, of the version appendix A gives (2), sets nothing.
|
||||
let text = live_graph().replace("GC\n", "GC\n%_2 Gt\n%_GT\n");
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(art(&r.doc)[0].extras().graph.is_some());
|
||||
}
|
||||
|
||||
/// A graph keeps its drawn art and stays plain, with one note, when a record has a shape or value
|
||||
/// appendix A doesn't give, sets what the model doesn't keep, or the table, the series tags or the
|
||||
/// art don't fit the graph.
|
||||
#[test]
|
||||
fn graphs_the_model_cant_keep_stay_plain_art() {
|
||||
let cases: Vec<(&str, String)> = vec![
|
||||
("an eleventh style value", live_graph().replace(LIVE_STYLE, "%_5 0 0 14 0 1 0 0 6 44 0 Gy\n")),
|
||||
("eleven dialog values", live_graph().replace(LIVE_DIALOG, "%_10 2 0 0 90 80 0 0 0 0 0 Gd\n")),
|
||||
("a fourth axis", live_graph().replace(LIVE_AXES, &format!("{LIVE_AXES}%_8 () Ga\n%_0 14 0 0 0 0 0 () GA\n"))),
|
||||
("ten axis values", live_graph().replace("%_0 14 0 0 0 0 0 () GA", "%_0 14 0 0 0 0 0 () 0 0 GA")),
|
||||
("a pie graph", live_graph().replace("%_5 0 0 14", "%_8 0 0 14")),
|
||||
("a scatter graph", live_graph().replace("%_5 0 0 14", "%_9 0 0 14")),
|
||||
("type 11", live_graph().replace("%_5 0 0 14", "%_11 0 0 14")),
|
||||
("legends in wedges", live_graph().replace("%_5 0 0 14", "%_5 0 0 15")),
|
||||
("the right axis", live_graph().replace("6 44 Gy", "6 45 Gy")),
|
||||
("lines as shapes", live_graph().replace("%_5 0 0 14 0 1 0", "%_5 0 0 14 0 1 1")),
|
||||
("both axes alike", live_graph().replace(LIVE_DIALOG, "%_10 2 0 1 90 80 0 Gd\n")),
|
||||
("a subscription", live_graph().replace(LIVE_DIALOG, &format!("{LIVE_DIALOG}%_0 0 1 1 1 Gj\n"))),
|
||||
("a customization", live_graph().replace("GC\n", "GC\n%_2 Gt\n%_(Bars) GD\n%_GT\n")),
|
||||
("customizations without their version", live_graph().replace("GC\n", "GC\n%_Gt\n%_GT\n")),
|
||||
("customizations of another version", live_graph().replace("GC\n", "GC\n%_3 Gt\n%_GT\n")),
|
||||
("bounds upside down", live_graph().replace("%_100 200 300 100 Gb", "%_100 100 300 200 Gb")),
|
||||
("a plot the axes aren't on", live_graph().replace("%_100 200 300 100 Gb", "%_50 200 300 100 Gb")),
|
||||
("a text data cell", live_graph().replace("7 () Gc", "(seven) Gc () Gc")),
|
||||
("a table of empty cells", live_graph().replace(LIVE_TABLE, "%_4 3 1 1 Gz\n%_() Gc (East) Gc (West) Gc\n%_GC\n")),
|
||||
("a cell past the table", live_graph().replace("4 6 Gc GC", "4 6 1 Gc GC")),
|
||||
("too many categories", live_graph().replace("%_4 3 1 1 Gz", "%_10002 3 1 1 Gz")),
|
||||
("a table too large", live_graph().replace("%_4 3 1 1 Gz", "%_4000000000 4000000000 1 1 Gz")),
|
||||
("a number as a label", live_graph().replace("(West) Gc", "2025 Gc")),
|
||||
("a series untagged", live_graph().replace("%_2 2 0 0 Go", "%_4 2 0 0 Go")),
|
||||
("a series tagged twice", live_graph().replace("%_2 2 0 0 Go", "%_2 1 0 0 Go")),
|
||||
("a path tagged as a series", live_graph().replacen("%_4 1 0 0 Go", "%_2 1 0 0 Go", 1)),
|
||||
("a target the specification doesn't list", live_graph().replace("%_9 0 0 2 Go", "%_3 0 0 2 Go")),
|
||||
("art before its group", live_graph().replace("GC\nu\n", &format!("GC\n{}f\nu\n", rect_path(0.0, 0.0, 5.0, 5.0)))),
|
||||
("a record on a line that shows", live_graph().replace("%_10 2 0 0 90 80 0 Gd", "10 2 0 0 90 80 0 Gd")),
|
||||
("series groups inside another group", live_graph().replace("GC\nu\n", "GC\nu\nu\n").replace("U\n%_GS", "U\nU\n%_GS")),
|
||||
(
|
||||
"two fills in one series",
|
||||
live_graph().replacen(
|
||||
"f\n%_4 1 0 0 Go\nU\n",
|
||||
&format!("f\n%_4 1 0 0 Go\n0 0 0 0.5 k\n{}f\nU\n", rect_path(112.0, 100.0, 118.0, 120.0)),
|
||||
1,
|
||||
),
|
||||
),
|
||||
("axes filled, not stroked", live_graph().replace("300 100 L\nS\n", "300 100 L\nf\n")),
|
||||
("a series tagged on a row", live_graph().replace("%_2 2 0 0 Go", "%_2 2 1 0 Go")),
|
||||
("a series tagged on the right axis", live_graph().replace("%_2 2 0 0 Go", "%_2 2 0 4 Go")),
|
||||
("an unfilled mark in a column series", live_graph().replacen(&series_mark(), &series_mark_and_outline(), 1)),
|
||||
("a series' mark in a group of its own", live_graph().replacen(&series_mark(), &format!("u\n{}U\n", series_mark()), 1)),
|
||||
(
|
||||
"an untagged graph of one series",
|
||||
live_graph()
|
||||
.replace(LIVE_TABLE, "%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n")
|
||||
.replace("%_2 2 0 0 Go", "%_4 1 0 0 Go")
|
||||
.replace("%_2 1 0 0 Go", "%_4 1 0 0 Go"),
|
||||
),
|
||||
];
|
||||
for (what, text) in cases {
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN], "{what}");
|
||||
assert_eq!(live_graphs(&r.doc), (vec![], 0), "{what}");
|
||||
let mut kept = false;
|
||||
r.doc.walk(|n| kept |= n.children().is_some_and(|c| c.len() == 3));
|
||||
assert!(kept, "{what}: the drawn art is kept");
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph in a group or a clipping group keeps its data on its own group, not the container's; of
|
||||
/// two graphs in one layer, each is kept or left plain on its own, and the note is given once.
|
||||
#[test]
|
||||
fn graphs_are_kept_one_by_one_wherever_they_are() {
|
||||
let clip = format!("q\n{}W n\n{}Q\n", rect_path(0.0, 0.0, 400.0, 400.0), live_graph());
|
||||
for (what, text) in [("in a group", format!("u\n{}U\n", live_graph())), ("in a clipping group", clip)] {
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings);
|
||||
let outer = &art(&r.doc)[0];
|
||||
assert!(outer.extras().graph.is_none(), "{what}");
|
||||
let (graphs, tagged) = live_graphs(&r.doc);
|
||||
assert_eq!((graphs.len(), tagged), (1, 2), "{what}");
|
||||
assert!(outer.children().unwrap().iter().any(|c| c.id == graphs[0].id), "{what}: the graph's own group");
|
||||
}
|
||||
let pie = live_graph().replace("%_5 0 0 14", "%_8 0 0 14");
|
||||
let body = format!("{}{pie}{}", live_graph(), live_graph());
|
||||
let r = read(stream7(&layer7("Graph", &body)).as_bytes()).unwrap();
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN]);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 3);
|
||||
let live: Vec<bool> = a.iter().map(|n| n.extras().graph.is_some()).collect();
|
||||
assert_eq!(live, [true, false, true]);
|
||||
}
|
||||
|
||||
/// Editing data in the legacy format (`%AI5_FileFormat 2.0`, ten `Lb` operands), then `body`.
|
||||
fn mask_stream7(body: &str) -> String {
|
||||
format!(
|
||||
|
||||
@@ -124,6 +124,7 @@ const STROKES: &str =
|
||||
"strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines";
|
||||
const IMAGE_ALPHA: &str = "transparent image pixels are written over white";
|
||||
const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art";
|
||||
const GRAPHS: &str = "graphs are written as their art, without their data or settings";
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{
|
||||
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
|
||||
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
|
||||
};
|
||||
use super::{CONIC, CONIC_WEDGES, DIAMOND, HUE_STOPS, SPREAD, SPREAD_CAPPED};
|
||||
use super::{CONIC, CONIC_WEDGES, DIAMOND, GRAPHS, HUE_STOPS, SPREAD, SPREAD_CAPPED};
|
||||
|
||||
pub(super) struct Writer<'a> {
|
||||
doc: &'a Document,
|
||||
@@ -254,6 +254,9 @@ impl<'a> Writer<'a> {
|
||||
if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() {
|
||||
self.warn(NAMES);
|
||||
}
|
||||
if n.extras().graph.is_some() {
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
let locked = locked || n.locked;
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip, .. } => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ use super::entry::{
|
||||
UNNAMED_CHARACTERS_NOTE, max_objects,
|
||||
};
|
||||
use super::reshaped;
|
||||
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer};
|
||||
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, same_stroke, stroke_layer};
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget,
|
||||
FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint,
|
||||
@@ -445,15 +445,23 @@ impl<'p> Builder<'p> {
|
||||
if let Some((t, opacity, facts)) = line.finish() {
|
||||
if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) {
|
||||
outlined = true;
|
||||
if self.mask.is_some() {
|
||||
self.flush_glyphs();
|
||||
self.flush_masked();
|
||||
}
|
||||
for g in glyphs {
|
||||
let same = self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == g.paint
|
||||
&& r.opacity == g.opacity
|
||||
&& r.overprint == g.overprint
|
||||
&& r.stroke.is_some() == g.stroke.is_some()
|
||||
&& r.stroke.as_ref().map(|s| s.line_width) == g.stroke.as_ref().map(|s| s.line_width)
|
||||
&& (r.scale - g.scale).abs() < 1e-9
|
||||
});
|
||||
// Glyphs the file fills one by one composite where they overlap: only opaque
|
||||
// ones, drawn alike down to the stroke, and not under a soft mask (which
|
||||
// would be applied once to all of them), share a path.
|
||||
let same = g.opacity >= 0.999
|
||||
&& self.mask.is_none()
|
||||
&& self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == g.paint
|
||||
&& r.opacity == g.opacity
|
||||
&& r.overprint == g.overprint
|
||||
&& same_stroke(r.stroke.as_ref(), g.stroke.as_ref())
|
||||
&& (r.scale - g.scale).abs() < 1e-9
|
||||
});
|
||||
match &mut self.glyphs {
|
||||
Some(r) if same => r.path.extend(g.path.iter()),
|
||||
_ => {
|
||||
@@ -461,6 +469,12 @@ impl<'p> Builder<'p> {
|
||||
self.glyphs = Some(g);
|
||||
}
|
||||
}
|
||||
// Under a soft mask each glyph is masked on its own: the art queued for the
|
||||
// mask is drained at each glyph, or one group of them all takes it once.
|
||||
if self.mask.is_some() {
|
||||
self.flush_glyphs();
|
||||
self.flush_masked();
|
||||
}
|
||||
}
|
||||
self.warn(RESHAPED_TEXT_NOTE);
|
||||
} else {
|
||||
|
||||
@@ -235,6 +235,23 @@ pub(super) fn line_width(p: &StrokeProps, scale: f64) -> f64 {
|
||||
if p.line_width == 0.0 { HAIRLINE } else { p.line_width as f64 * scale }
|
||||
}
|
||||
|
||||
/// Do strokes `a` and `b` draw alike: both unstroked, or the same width, caps, joins, miter limit
|
||||
/// and dashes ([`stroke_layer`] takes all of them)?
|
||||
pub(super) fn same_stroke(a: Option<&StrokeProps>, b: Option<&StrokeProps>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
a.line_width == b.line_width
|
||||
&& a.line_cap == b.line_cap
|
||||
&& a.line_join == b.line_join
|
||||
&& a.miter_limit == b.miter_limit
|
||||
&& a.dash_array == b.dash_array
|
||||
&& a.dash_offset == b.dash_offset
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(paint, line_width(p, scale));
|
||||
st.opacity = opacity;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! ([`vectorcraft_text::test_fonts::matra_font`]): Ka draws Source Sans 3's `a` and the vowel sign
|
||||
//! I, set before its consonant, draws its `i`.
|
||||
|
||||
use kurbo::{Rect, Shape};
|
||||
use vectorcraft_doc::{Document, NodeKind};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
use vectorcraft_text::FontDb;
|
||||
@@ -29,6 +30,14 @@ fn glyph(c: char) -> (u32, f64) {
|
||||
/// and a ToUnicode map naming each glyph's character. `sign_width`: the advance the file gives
|
||||
/// the vowel sign, in thousandths of an em (`None`: the font's).
|
||||
fn page(drawn: &[char], sign_width: Option<f64>) -> Vec<u8> {
|
||||
page_with(|id| format!("[{}] TJ", drawn.iter().map(|&c| id(c)).collect::<String>()), "", &[], sign_width)
|
||||
}
|
||||
|
||||
/// [`page`] showing glyphs with the text operators `ops` makes (after the position of the first
|
||||
/// glyph) from a function giving a character's glyph id in hex, and `extra` more resources: a
|
||||
/// graphics state, to set between glyphs or before them, with `objects` it refers to (the first
|
||||
/// is object `first_extra(1) + 4`).
|
||||
fn page_with(ops: impl Fn(&dyn Fn(char) -> String) -> String, extra: &str, objects: &[String], sign_width: Option<f64>) -> Vec<u8> {
|
||||
FontDb::global().add_font(matra_font().expect("the test font builds"));
|
||||
let program = matra_font().unwrap();
|
||||
let hex: String = program.iter().map(|b| format!("{b:02X}")).collect::<String>() + ">";
|
||||
@@ -55,14 +64,13 @@ fn page(drawn: &[char], sign_width: Option<f64>) -> Vec<u8> {
|
||||
),
|
||||
),
|
||||
];
|
||||
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||||
let refs: Vec<&str> = objs.iter().chain(objects).map(String::as_str).collect();
|
||||
let resources = format!(
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >>",
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >> {extra}",
|
||||
n + 2,
|
||||
n + 3
|
||||
);
|
||||
let shown: String = drawn.iter().map(|&c| format!("{:04X}", glyph(c).0)).collect();
|
||||
let content = format!("BT /F1 40 Tf 20 100 Td <{shown}> Tj ET");
|
||||
let content = format!("BT /F1 40 Tf 20 100 Td {} ET", ops(&|c| format!("<{:04X}>", glyph(c).0)));
|
||||
pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, &content) }], &refs, None)
|
||||
}
|
||||
|
||||
@@ -117,3 +125,96 @@ fn a_mark_without_an_advance_stays_with_its_line() {
|
||||
assert_eq!(texts(&report.document), [""; 0], "the mark is no type of its own");
|
||||
assert_eq!(outlines(&report.document), 1, "both glyphs, as one path");
|
||||
}
|
||||
|
||||
/// The file's glyphs are outlines of one path when drawn alike, and a path each when a stroke
|
||||
/// property other than the width (here the dash) differs between them: the first glyph's would
|
||||
/// otherwise be drawn on all.
|
||||
#[test]
|
||||
fn outlined_glyphs_stroked_differently_stay_apart() {
|
||||
// Stroked (`1 Tr`) at the same width, one glyph solid and the next dashed.
|
||||
let alike = page_with(|id| format!("1 Tr 2 w {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), "", &[], None);
|
||||
let apart = page_with(|id| format!("1 Tr 2 w {} Tj [3 2] 0 d {} Tj [] 0 d {} Tj", id(KA), id(SIGN_I), id(KA)), "", &[], None);
|
||||
assert_eq!(outlines(&open(&alike).document), 1, "drawn alike: one path");
|
||||
assert_eq!(outlines(&open(&apart).document), 3, "solid, dashed, solid: a path each");
|
||||
}
|
||||
|
||||
/// Translucent glyphs the file fills one by one composite where they overlap, which one fill of
|
||||
/// their joined outlines does not do: each keeps a path of its own.
|
||||
#[test]
|
||||
fn outlined_translucent_glyphs_stay_apart() {
|
||||
let bytes = page_with(|id| format!("/G gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), "/ExtGState << /G << /ca 0.5 >> >>", &[], None);
|
||||
let report = open(&bytes);
|
||||
assert_eq!(texts(&report.document), [""; 0]);
|
||||
assert_eq!(outlines(&report.document), 3, "a path each");
|
||||
}
|
||||
|
||||
/// Glyphs the file draws under a soft mask are not joined into one path: the mask would be applied
|
||||
/// once to the whole, where the file applies it to each glyph.
|
||||
#[test]
|
||||
fn outlined_glyphs_under_a_soft_mask_stay_apart() {
|
||||
// A luminosity mask (object `first_extra(1) + 4`) of mid gray over the page.
|
||||
let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency /CS /DeviceGray >>", "0.5 g 0 0 300 200 re f");
|
||||
let mask = format!("/ExtGState << /M << /SMask << /S /Luminosity /G {} 0 R >> >> >>", first_extra(1) + 4);
|
||||
let bytes = page_with(|id| format!("/M gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), &mask, &[group], None);
|
||||
let report = open(&bytes);
|
||||
assert_eq!(texts(&report.document), [""; 0]);
|
||||
assert_eq!(outlines(&report.document), 3, "a path each");
|
||||
}
|
||||
|
||||
/// What the glyph outlines of `d` draw: how many path elements, and the box round them all.
|
||||
fn outline_geometry(d: &Document) -> (usize, Rect) {
|
||||
let (mut elements, mut bounds) = (0, None::<Rect>);
|
||||
d.walk(|n| {
|
||||
if is_glyph_outlines(n)
|
||||
&& let Some(p) = n.path_data()
|
||||
{
|
||||
let path = p.to_bezpath();
|
||||
elements += path.elements().len();
|
||||
let b = path.bounding_box();
|
||||
bounds = Some(bounds.map_or(b, |u| u.union(b)));
|
||||
}
|
||||
});
|
||||
(elements, bounds.unwrap_or(Rect::ZERO))
|
||||
}
|
||||
|
||||
/// A line kept as outlines draws the glyphs where they are drawn when the file is opened as
|
||||
/// outlines: none dropped or moved.
|
||||
#[test]
|
||||
fn kept_glyphs_are_where_the_file_draws_them() {
|
||||
for (drawn, sign_width) in [(vec![KA, SIGN_I, KA], None), (vec![KA, SIGN_I], Some(0.0))] {
|
||||
let bytes = page(&drawn, sign_width);
|
||||
let kept = open(&bytes);
|
||||
let all = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap();
|
||||
assert_eq!(texts(&kept.document), [""; 0], "the line is kept as outlines");
|
||||
let (kept, all) = (outline_geometry(&kept.document), outline_geometry(&all.document));
|
||||
assert!(all.0 > 0, "the glyphs have outlines");
|
||||
assert_eq!(kept.0, all.0, "every outline element is kept");
|
||||
for (a, b) in [(kept.1.x0, all.1.x0), (kept.1.y0, all.1.y0), (kept.1.x1, all.1.x1), (kept.1.y1, all.1.y1)] {
|
||||
assert!((a - b).abs() < 1e-6, "{:?} vs {:?}", kept.1, all.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many glyph outlines carry a soft mask of their own.
|
||||
fn masked_outlines(d: &Document) -> usize {
|
||||
let mut n = 0;
|
||||
d.walk(|node| n += usize::from(is_glyph_outlines(node) && node.extras().mask.is_some()));
|
||||
n
|
||||
}
|
||||
|
||||
/// A mask that isn't constant (dark on the left, light on the right) is a mask of its own on each
|
||||
/// glyph, not one on a group of them: glyphs that overlap composite one by one, as the file draws
|
||||
/// them.
|
||||
#[test]
|
||||
fn outlined_glyphs_under_a_varying_soft_mask_are_masked_one_by_one() {
|
||||
let group = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency /CS /DeviceGray >>",
|
||||
"0.2 g 0 0 150 200 re f 0.9 g 150 0 150 200 re f",
|
||||
);
|
||||
let mask = format!("/ExtGState << /M << /SMask << /S /Luminosity /G {} 0 R >> >> >>", first_extra(1) + 4);
|
||||
let bytes = page_with(|id| format!("/M gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), &mask, &[group], None);
|
||||
let report = open(&bytes);
|
||||
assert_eq!(texts(&report.document), [""; 0]);
|
||||
assert_eq!(outlines(&report.document), 3, "a path each");
|
||||
assert_eq!(masked_outlines(&report.document), 3, "each path has its own mask");
|
||||
}
|
||||
|
||||
@@ -107,7 +107,12 @@ impl BrushCache {
|
||||
vectorcraft_brush::serde_json::to_string(v).unwrap_or_default().hash(&mut h);
|
||||
h.finish()
|
||||
});
|
||||
self.lib = vectorcraft_brush::library(doc).into_iter().map(|b| (b.name.clone(), Arc::new(b))).collect();
|
||||
// Of two brushes with one name, a stroke that names it draws with the first, as
|
||||
// `vectorcraft_brush::find` and the exports take it.
|
||||
self.lib.clear();
|
||||
for b in vectorcraft_brush::library(doc) {
|
||||
self.lib.entry(b.name.clone()).or_insert_with(|| Arc::new(b));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::key`] for the brush library `doc` holds (checked once a frame, `stamp`).
|
||||
@@ -388,6 +393,24 @@ mod tests {
|
||||
assert!(!dark(a.pixel(100, 58)) && dark(b.pixel(100, 58)));
|
||||
}
|
||||
|
||||
/// Of two brushes with one name, a stroke that names it draws with the first.
|
||||
#[test]
|
||||
fn a_stroke_draws_with_the_first_of_two_brushes_with_its_name() {
|
||||
use vectorcraft_brush::{Brush, BrushKind, Calligraphic};
|
||||
let nib = |size: f64| Brush { name: "Twin".into(), kind: BrushKind::Calligraphic(Calligraphic { size, ..Default::default() }) };
|
||||
let doc = |lib: &[Brush]| {
|
||||
let mut d = doc_with_line(Some("Twin"), 1.0);
|
||||
vectorcraft_brush::store(&mut d, lib);
|
||||
d
|
||||
};
|
||||
let (thin, wide) = (nib(1.0), nib(20.0));
|
||||
let first = render(&mut Renderer::new(), &doc(std::slice::from_ref(&thin))).pixels;
|
||||
let second = render(&mut Renderer::new(), &doc(std::slice::from_ref(&wide))).pixels;
|
||||
assert_ne!(first, second, "the two nibs paint differently");
|
||||
let both = render(&mut Renderer::new(), &doc(&[thin, wide])).pixels;
|
||||
assert!(both == first, "the stroke draws with the first brush named Twin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stained_symbol_instance() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
+43
-19
@@ -143,6 +143,38 @@ fn step_swatch(list: &[&Swatch], fill: &Paint, last: Option<usize>, forward: boo
|
||||
}
|
||||
}
|
||||
|
||||
/// Is no Shift, Alt, Cmd or Ctrl held? The arrows step swatches only then.
|
||||
fn plain(mods: Mods) -> bool {
|
||||
!(mods.shift || mods.alt || mods.cmd || mods.ctrl)
|
||||
}
|
||||
|
||||
/// The color swatch the Right (`forward`) or Left arrow key picks for the current `paint`, with its
|
||||
/// index in [`color_swatches`] (see [`step_swatch`]); None when the document has no color swatch.
|
||||
fn pick_swatch(doc: &Document, paint: &Paint, last: Option<usize>, forward: bool) -> Option<(usize, String)> {
|
||||
let list = color_swatches(doc);
|
||||
step_swatch(&list, paint, last, forward).and_then(|i| Some((i, list.get(i)?.name.clone())))
|
||||
}
|
||||
|
||||
/// The cursor swatch preview for the current `paint` with the pointer at `at`: the swatch before
|
||||
/// it, `paint` itself (ringed, while it is a solid color) and the swatch after it, side by side above
|
||||
/// the pointer. With `matched_only`, the swatches beside it show only while `paint` is one of the
|
||||
/// color swatches; otherwise the last and the first swatch stand beside a color that is none of them.
|
||||
fn swatch_chips(cx: &ToolContext, at: Point, paint: &Paint, last: Option<usize>, matched_only: bool) -> Vec<Overlay> {
|
||||
let list = color_swatches(cx.doc);
|
||||
let rgba = |p: &Paint| match p {
|
||||
Paint::Solid { color, .. } => Some(color.to_rgba8(1.0)),
|
||||
_ => None,
|
||||
};
|
||||
let beside = !matched_only || swatch_index(&list, paint, last).is_some();
|
||||
let chip = |forward| beside.then(|| step_swatch(&list, paint, last, forward)).flatten().and_then(|i| rgba(&list.get(i)?.paint));
|
||||
let (before, after) = (chip(false), chip(true));
|
||||
let y = at.y - cx.tol(22.0);
|
||||
[(before, -16.0, false), (rgba(paint), 0.0, true), (after, 16.0, false)]
|
||||
.into_iter()
|
||||
.filter_map(|(color, dx, selected)| Some(Overlay::Swatch { p: Point::new(at.x + cx.tol(dx), y), color: color?, selected }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Shape Builder tool: drag across regions to merge them. Holding Alt (Option) switches to erase
|
||||
/// mode while it is held: the pointer shows a minus, what a click or drag would delete highlights in
|
||||
/// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges
|
||||
@@ -425,20 +457,7 @@ impl ShapeBuilderTool {
|
||||
/// The cursor swatch preview: the swatch before the current fill, the current fill (ringed)
|
||||
/// and the swatch after it, side by side above the pointer.
|
||||
fn swatch_chips(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(at) = self.at else { return vec![] };
|
||||
let list = color_swatches(cx.doc);
|
||||
let fill = &cx.paint.fill;
|
||||
let rgba = |p: &Paint| match p {
|
||||
Paint::Solid { color, .. } => Some(color.to_rgba8(1.0)),
|
||||
_ => None,
|
||||
};
|
||||
let chip = |forward| step_swatch(&list, fill, self.picked, forward).and_then(|i| rgba(&list.get(i)?.paint));
|
||||
let (before, after) = (chip(false), chip(true));
|
||||
let y = at.y - cx.tol(22.0);
|
||||
[(before, -16.0, false), (rgba(fill), 0.0, true), (after, 16.0, false)]
|
||||
.into_iter()
|
||||
.filter_map(|(color, dx, selected)| Some(Overlay::Swatch { p: Point::new(at.x + cx.tol(dx), y), color: color?, selected }))
|
||||
.collect()
|
||||
self.at.map(|at| swatch_chips(cx, at, &cx.paint.fill, self.picked, false)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,9 +731,15 @@ impl Tool for ShapeBuilderTool {
|
||||
(self.opts.swatches && !self.dragging && matches!(key, ToolKey::Left | ToolKey::Right)) || (self.dragging && key == ToolKey::Escape)
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
/// The arrows pick swatches only without modifiers; Shift, Alt and Cmd/Ctrl arrows keep their
|
||||
/// shortcuts.
|
||||
fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> bool {
|
||||
self.claims_key(cx, key) && (key == ToolKey::Escape || plain(mods))
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
// While a merge is being computed, the arrows leave the current fill it takes as it was.
|
||||
if !self.claims_key(cx, key) || self.editing() {
|
||||
if !self.claims_key_with(cx, key, mods) || self.editing() {
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape {
|
||||
@@ -726,13 +751,12 @@ impl Tool for ShapeBuilderTool {
|
||||
(self.marquee, self.hover, self.far) = (None, None, false);
|
||||
return vec![];
|
||||
}
|
||||
let list = color_swatches(cx.doc);
|
||||
let Some((i, s)) = step_swatch(&list, &cx.paint.fill, self.picked, key == ToolKey::Right).and_then(|i| Some((i, list.get(i)?))) else {
|
||||
let Some((i, name)) = pick_swatch(cx.doc, &cx.paint.fill, self.picked, key == ToolKey::Right) else {
|
||||
return vec![];
|
||||
};
|
||||
self.picked = Some(i);
|
||||
// The current fill only (no `ids`): the selected art keeps its own.
|
||||
vec![Action::Exec("paint.setFill".into(), json!({ "swatch": s.name, "ids": [] }))]
|
||||
vec![Action::Exec("paint.setFill".into(), json!({ "swatch": name, "ids": [] }))]
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_e
|
||||
use super::map::{HIGHLIGHT_RED, sorted_roots};
|
||||
use super::{
|
||||
Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent,
|
||||
PointerKind, Rect, Tool, ToolContext, ToolKey, Value, json, json_ids, path_contains,
|
||||
PointerKind, Rect, Tool, ToolContext, ToolKey, Value, color_swatches, json, json_ids, path_contains, pick_swatch, plain, swatch_chips,
|
||||
};
|
||||
|
||||
// ---------- Live Paint Bucket ----------
|
||||
@@ -171,6 +171,14 @@ enum LpPaint {
|
||||
/// click paints nothing. With Highlight off, the face or edge under the pointer is not
|
||||
/// highlighted. The cursor is a filled bucket where a click fills a face, a brush where it paints
|
||||
/// an edge and an empty bucket where it paints nothing; with Alt it is the Eyedropper.
|
||||
///
|
||||
/// The Left and Right arrow keys without Shift, Alt or Cmd/Ctrl set the active paint (the fill,
|
||||
/// or the stroke when the Stroke proxy is in front) to the color swatch before or after it in the
|
||||
/// Swatches panel, wrapping around; from a color that is no swatch, Right picks the first swatch
|
||||
/// and Left the last. The selected art keeps its paint. With `cursorSwatchPreview` (on), the
|
||||
/// active paint shows as a chip above the pointer between the swatches before and after it,
|
||||
/// except during a fill drag or with Alt held; a solid color that is no swatch shows alone, and a
|
||||
/// paint that is not a solid color shows no chip.
|
||||
pub struct LivePaintBucketTool {
|
||||
pub(super) cache: RegionCache,
|
||||
pub(super) hover: LpHover,
|
||||
@@ -183,6 +191,11 @@ pub struct LivePaintBucketTool {
|
||||
highlight: bool,
|
||||
color: [u8; 3],
|
||||
width: f64,
|
||||
/// Cursor Swatch Preview.
|
||||
preview: bool,
|
||||
/// The index in [`color_swatches`] of the swatch the arrow keys picked last for the fill and for
|
||||
/// the stroke.
|
||||
picked: [Option<usize>; 2],
|
||||
}
|
||||
|
||||
/// Maximum points in one Live Paint fill path chunk.
|
||||
@@ -200,11 +213,24 @@ impl Default for LivePaintBucketTool {
|
||||
highlight: true,
|
||||
color: HIGHLIGHT_RED,
|
||||
width: LP_BUCKET_WIDTH,
|
||||
preview: true,
|
||||
picked: [None; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LivePaintBucketTool {
|
||||
/// The active paint the arrow keys step and the preview shows: the fill, or the stroke when the
|
||||
/// Stroke proxy is in front.
|
||||
fn active_paint<'a>(cx: &'a ToolContext) -> &'a vectorcraft_color::Paint {
|
||||
if cx.fill_active { &cx.paint.fill } else { &cx.paint.stroke }
|
||||
}
|
||||
|
||||
/// The slot of [`Self::picked`] for the active paint.
|
||||
fn picked_slot(cx: &ToolContext) -> usize {
|
||||
usize::from(!cx.fill_active)
|
||||
}
|
||||
|
||||
fn begin_fill(&mut self, params: Value, p: Point) -> Vec<Action> {
|
||||
self.fill_drag = Some((params.clone(), vec![vec![p]]));
|
||||
vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), params)]
|
||||
@@ -377,6 +403,8 @@ impl Tool for LivePaintBucketTool {
|
||||
}
|
||||
PointerKind::Drag => self.drag_fill(p),
|
||||
PointerKind::Up => {
|
||||
// The swatch preview shows again at the release point.
|
||||
self.at = Some((p, ev.mods.shift, ev.mods.alt));
|
||||
let mut acts = self.drag_fill(p);
|
||||
if self.fill_drag.take().is_some() {
|
||||
acts.push(Action::Commit);
|
||||
@@ -387,24 +415,63 @@ impl Tool for LivePaintBucketTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] }
|
||||
/// Escape during a fill drag cancels it. Left and Right pick the color swatch before or after
|
||||
/// the active paint, while no drag is on.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape {
|
||||
return if self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] };
|
||||
}
|
||||
if !self.claims_key_with(cx, key, mods) {
|
||||
return vec![];
|
||||
}
|
||||
let slot = Self::picked_slot(cx);
|
||||
let Some((i, name)) = pick_swatch(cx.doc, Self::active_paint(cx), self.picked[slot], key == ToolKey::Right) else {
|
||||
return vec![];
|
||||
};
|
||||
self.picked[slot] = Some(i);
|
||||
// The active paint only (no `ids`): the selected art keeps its own.
|
||||
let cmd = if cx.fill_active { "paint.setFill" } else { "paint.setStroke" };
|
||||
vec![Action::Exec(cmd.into(), json!({ "swatch": name, "ids": [] }))]
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.fill_drag.is_some()
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
key == ToolKey::Escape && self.busy()
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
match key {
|
||||
ToolKey::Escape => self.busy(),
|
||||
ToolKey::Left | ToolKey::Right => !self.busy() && !color_swatches(cx.doc).is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The arrows pick swatches only without modifiers; Shift, Alt and Cmd/Ctrl arrows keep their
|
||||
/// shortcuts.
|
||||
fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> bool {
|
||||
self.claims_key(cx, key) && (key == ToolKey::Escape || plain(mods))
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32)
|
||||
/// The pointer hovers nothing for the tool: its highlight and its swatch preview go.
|
||||
fn hover_ended(&mut self, _cx: &ToolContext) {
|
||||
self.hover = LpHover::None;
|
||||
self.at = None;
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32);
|
||||
// No chips during a fill drag, or while Alt makes the tool sample with the eyedropper.
|
||||
if self.preview
|
||||
&& !self.busy()
|
||||
&& let Some((at, _, false)) = self.at
|
||||
{
|
||||
out.extend(swatch_chips(cx, at, Self::active_paint(cx), self.picked[Self::picked_slot(cx)], true));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
@@ -416,6 +483,7 @@ impl Tool for LivePaintBucketTool {
|
||||
json!({
|
||||
"paintFills": self.fills,
|
||||
"paintStrokes": self.strokes,
|
||||
"cursorSwatchPreview": self.preview,
|
||||
"highlight": self.highlight,
|
||||
"color": format!("#{r:02x}{g:02x}{b:02x}"),
|
||||
"width": self.width,
|
||||
@@ -426,6 +494,7 @@ impl Tool for LivePaintBucketTool {
|
||||
match key {
|
||||
"paintFills" => self.fills = v.as_bool().unwrap_or(self.fills),
|
||||
"paintStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes),
|
||||
"cursorSwatchPreview" => self.preview = v.as_bool().unwrap_or(self.preview),
|
||||
"highlight" => self.highlight = v.as_bool().unwrap_or(self.highlight),
|
||||
"color" => self.color = lp_color(v).unwrap_or(self.color),
|
||||
"width" => {
|
||||
@@ -612,7 +681,7 @@ pub(super) fn lp_rgb_color(v: &Value) -> bool {
|
||||
/// value it cannot read.
|
||||
pub fn check_live_paint_options(tool: &str, values: &serde_json::Map<String, Value>) -> Result<(), String> {
|
||||
let switches: &[&str] = match tool {
|
||||
"livePaintBucket" => &["paintFills", "paintStrokes", "highlight"],
|
||||
"livePaintBucket" => &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight"],
|
||||
"livePaintSelection" => &["selectFills", "selectStrokes"],
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
@@ -1087,6 +1087,42 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() {
|
||||
assert_eq!(id, "livePaint.strokeEdge");
|
||||
}
|
||||
|
||||
/// After a fill drag, the cursor swatch preview shows at the release point, not where the pointer
|
||||
/// last moved before the press.
|
||||
#[test]
|
||||
fn bucket_swatch_preview_follows_the_release_point() {
|
||||
let (d, ..) = live_group_doc();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
let chip_x = |t: &LivePaintBucketTool| {
|
||||
t.overlays(&cx).iter().find_map(|o| if let Overlay::Swatch { p, selected: true, .. } = o { Some(p.x) } else { None })
|
||||
};
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 25.0, 25.0));
|
||||
assert_eq!(chip_x(&t), Some(25.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 25.0));
|
||||
assert_eq!(chip_x(&t), None, "no chips during the drag");
|
||||
t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0));
|
||||
assert_eq!(chip_x(&t), Some(75.0));
|
||||
}
|
||||
|
||||
/// When the pointer leaves the canvas, the Live Paint Bucket's swatch preview and highlight go.
|
||||
#[test]
|
||||
fn bucket_hover_ended_drops_the_swatch_preview_and_highlight() {
|
||||
let (d, ..) = live_group_doc();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
|
||||
let ov = t.overlays(&cx);
|
||||
assert!(ov.iter().any(|o| matches!(o, Overlay::Swatch { .. })) && ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "{ov:?}");
|
||||
t.hover_ended(&cx);
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_options_choose_what_a_click_paints() {
|
||||
let (d, gid, ..) = live_group_doc();
|
||||
@@ -1113,7 +1149,12 @@ fn bucket_options_choose_what_a_click_paints() {
|
||||
};
|
||||
let (face, edge) = (Some(Rect::new(50.0, 0.0, 100.0, 50.0)), Some(Rect::new(50.0, 0.0, 50.0, 50.0)));
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
assert_eq!(t.options(), json!({"paintFills": true, "paintStrokes": false, "highlight": true, "color": "#ff2a2a", "width": 4.0}));
|
||||
assert_eq!(
|
||||
t.options(),
|
||||
json!({"paintFills": true, "paintStrokes": false, "cursorSwatchPreview": true, "highlight": true, "color": "#ff2a2a", "width": 4.0})
|
||||
);
|
||||
// The highlight alone, without the swatch chips.
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
// Paint Fills alone: 1 pt from the edge, a click fills the face and Shift-click strokes the edge.
|
||||
assert_eq!((click(&mut t, 51.0, Mods::default()), hovered(&mut t, 51.0, Mods::default())), (fill(51.0), face));
|
||||
assert_eq!((click(&mut t, 51.0, shift), hovered(&mut t, 51.0, shift)), (stroke(51.0), edge));
|
||||
@@ -1231,6 +1272,7 @@ fn bucket_fills_a_region_of_a_selection_that_mixes_a_group_and_paths() {
|
||||
s.set([g]);
|
||||
let live = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
t.pointer(&live, &ev(PointerKind::Move, pos.x, pos.y));
|
||||
t.poll(&live, true);
|
||||
assert!(t.overlays(&live).is_empty());
|
||||
@@ -1245,6 +1287,7 @@ fn bucket_strokes_alone_paints_and_highlights_no_region_of_the_selection() {
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
|
||||
t.poll(&cx, true);
|
||||
assert!(!t.overlays(&cx).is_empty(), "Paint Fills highlights the region");
|
||||
@@ -1263,6 +1306,7 @@ fn bucket_options_set_the_highlight() {
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
let highlight = |t: &mut LivePaintBucketTool| {
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
|
||||
match t.overlays(&cx).as_slice() {
|
||||
@@ -1299,6 +1343,7 @@ fn bucket_highlights_an_edge_thinner_than_a_face() {
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
let width = |t: &mut LivePaintBucketTool, x: f64, shift: bool| {
|
||||
t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(Mods { shift, ..Default::default() }));
|
||||
match t.overlays(&cx).as_slice() {
|
||||
@@ -1517,7 +1562,7 @@ fn bucket_hover_builds_a_large_map_on_another_thread() {
|
||||
poll_until_ready(&mut t, &cx);
|
||||
assert!(matches!(t.hover, LpHover::Region(_)), "{:?}", t.hover);
|
||||
assert_eq!((t.cache.builds, t.cache.background), (1, 1));
|
||||
assert_eq!(t.overlays(&cx).len(), 1);
|
||||
assert_eq!(t.overlays(&cx).iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1883,6 +1928,7 @@ fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() {
|
||||
};
|
||||
assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]);
|
||||
let mut bucket = LivePaintBucketTool::default();
|
||||
bucket.set_option("cursorSwatchPreview", &json!(false));
|
||||
bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift));
|
||||
assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2));
|
||||
let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, y], "tolerance": 4.0 })));
|
||||
@@ -1926,6 +1972,7 @@ fn live_paint_tools_skip_hidden_edges() {
|
||||
};
|
||||
assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}");
|
||||
let mut bucket = LivePaintBucketTool::default();
|
||||
bucket.set_option("cursorSwatchPreview", &json!(false));
|
||||
bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift));
|
||||
assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}");
|
||||
let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, 50.0], "tolerance": 4.0 })));
|
||||
@@ -1953,6 +2000,7 @@ fn live_paint_tools_skip_hidden_faces() {
|
||||
};
|
||||
// Where the faces overlap, both tools take the back group's face under the hidden one.
|
||||
let mut bucket = LivePaintBucketTool::default();
|
||||
bucket.set_option("cursorSwatchPreview", &json!(false));
|
||||
bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
|
||||
assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
let fill = Action::Preview("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] }));
|
||||
|
||||
@@ -182,6 +182,11 @@ pub trait Tool: Send {
|
||||
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
|
||||
false
|
||||
}
|
||||
/// [`Self::claims_key`] for a press with `mods` held. By default the modifiers do not matter;
|
||||
/// a tool that takes a key only without modifiers leaves the modified presses to the shortcuts.
|
||||
fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> bool {
|
||||
self.claims_key(cx, key)
|
||||
}
|
||||
/// Has the tool started something that Escape ends while no button is pressed (a path being
|
||||
/// drawn, the Blend tool's first pick)? That Escape ends nothing else, such as isolation mode or
|
||||
/// a rotated view.
|
||||
|
||||
@@ -46,7 +46,7 @@ const ROWS: &[Row] = &[
|
||||
// Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`).
|
||||
(&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]),
|
||||
// Live Paint Bucket Options (`builder::LivePaintBucketTool`).
|
||||
(&["livePaintBucket"], None, &["paintFills", "paintStrokes", "highlight", "color", "width"]),
|
||||
(&["livePaintBucket"], None, &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight", "color", "width"]),
|
||||
(&["polygon"], None, &["sides"]),
|
||||
(&["star"], None, &["points"]),
|
||||
(&["freeTransform"], None, &["constrain"]),
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! The 8 x 8 block edges of a block-coded image (a JPEG, or a copy of one saved in another format)
|
||||
//! smoothed away before it is traced.
|
||||
//!
|
||||
//! A JPEG codes each 8 x 8 block on its own and rounds each block's brightness in coarse steps, which
|
||||
//! leaves a small step at every block edge. Where an area's tone changes slowly, as a dark ground does,
|
||||
//! those steps decide which of two palette colors a whole block gets, and the trace follows the block
|
||||
//! edges as a staircase. The block grid is found in the image itself: the mean step between
|
||||
//! neighboring pixels peaks at one position in every eight, across and down, at every block edge and
|
||||
//! not only at every other one (a pattern of 16 px squares has that too). Where it does, the step
|
||||
//! at each block edge is spread evenly over the eight pixels around it when it is small and those
|
||||
//! pixels hold no edge. Larger steps are left as they are. How large a step is smoothed, and whether
|
||||
//! each row keeps part of its own step, depends on the trace's mode and number of colors (see
|
||||
//! [`Strength`]). The deblocking filters of ITU-T H.263
|
||||
//! Annex J and ISO/IEC 14496-2 (MPEG-4 Part 2) Annex F.3.1 treat block edges the same way: small steps
|
||||
//! between blocks become ramps, and large ones are kept.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::{Raster, parallel_map, quantize};
|
||||
|
||||
/// The block size of a JPEG.
|
||||
const BLOCK: usize = 8;
|
||||
/// Steps between neighboring pixels of this many levels or more are edges, left out when the grid is
|
||||
/// measured. No block-edge step this large is smoothed.
|
||||
const EDGE: f64 = 16.0;
|
||||
/// The mean step at the block edges must be this many times the mean step at the other positions,
|
||||
/// across and down, for the image to count as block-coded.
|
||||
const PEAK: f64 = 1.5;
|
||||
/// The smallest limit on the block-edge steps that are smoothed (levels, after the image's own slope
|
||||
/// is taken out); the limit is under `EDGE` too.
|
||||
const LEAST: f64 = 2.5;
|
||||
/// Fixed-point scale of the working copy: sixteenths of a level.
|
||||
const SCALE: f64 = 16.0;
|
||||
|
||||
/// How the block edges are smoothed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Strength {
|
||||
/// The largest block-edge step that is smoothed: this many times the extra step the block edges
|
||||
/// show on average, at least `LEAST` and under `EDGE`.
|
||||
reach: f64,
|
||||
/// A block edge with a step of this many levels or more between neighbors in the pixels around
|
||||
/// it is left as it is.
|
||||
guard: f64,
|
||||
/// How far one row's step may differ from its stretch's mean step and still be smoothed, as a
|
||||
/// share of the limit. The rest of a row's own step is kept.
|
||||
row_share: f64,
|
||||
}
|
||||
|
||||
/// For a trace in black and white, which thresholds the image without smoothing it first: small
|
||||
/// steps only, each row with its own.
|
||||
pub(crate) const BLACK_AND_WHITE: Strength = Strength { reach: 2.0, guard: 8.0, row_share: 0.5 };
|
||||
/// For a trace of up to 16 colors: each row with its own step.
|
||||
pub(crate) const FEW_COLORS: Strength = Strength { reach: 8.0, guard: 13.0, row_share: 0.5 };
|
||||
/// For a trace of more than 16 colors: larger steps, but only each stretch's mean step, so that
|
||||
/// every row of a stretch moves by the same amount.
|
||||
pub(crate) const MANY_COLORS: Strength = Strength { reach: 4.0, guard: EDGE, row_share: 0.0 };
|
||||
|
||||
/// Where the block edges of a block-coded image are, and how large their steps are.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Grid {
|
||||
/// A block edge lies between columns `x - 1` and `x` when `x % 8 == across`.
|
||||
across: usize,
|
||||
/// A block edge lies between rows `y - 1` and `y` when `y % 8 == down`.
|
||||
down: usize,
|
||||
/// The extra step the block edges show on average over the other positions, across or down,
|
||||
/// whichever is smaller.
|
||||
extra: f64,
|
||||
}
|
||||
|
||||
/// Positions counted when the grid is measured: two blocks, so that a pattern of 16 px or more
|
||||
/// (a checkerboard, cells of a picture enlarged 16 times), whose edges fall on every other block
|
||||
/// edge only, is not taken for blocks.
|
||||
const SPAN: usize = 2 * BLOCK;
|
||||
|
||||
/// For one direction, from the mean step between neighboring opaque pixels at each of 16 positions:
|
||||
/// the position (of eight) where it peaks at both block edges of the span, the extra step those
|
||||
/// show over the other positions, and whether there is no step at all at the other positions; `None`
|
||||
/// when no position stands out.
|
||||
fn peak(sum: &[f64; SPAN], count: &[u64; SPAN]) -> Option<(usize, f64, bool)> {
|
||||
if count.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
let mean: Vec<f64> = sum.iter().zip(count).map(|(&s, &n)| s / n as f64).collect();
|
||||
let pair = |at: usize| (mean.get(at).copied().unwrap_or(0.0), mean.get(at + BLOCK).copied().unwrap_or(0.0));
|
||||
let at = (0..BLOCK).max_by(|&a, &b| {
|
||||
let (pa, pb) = (pair(a), pair(b));
|
||||
pa.0.min(pa.1).total_cmp(&pb.0.min(pb.1))
|
||||
})?;
|
||||
let (first, second) = pair(at);
|
||||
let others = || mean.iter().enumerate().filter(|&(i, _)| i != at && i != at + BLOCK).map(|(_, &m)| m);
|
||||
let rest = others().sum::<f64>() / (SPAN - 2) as f64;
|
||||
(first.min(second) > PEAK * rest).then_some((at, (first + second) / 2.0 - rest, others().all(|m| m == 0.0)))
|
||||
}
|
||||
|
||||
/// The block grid of `img`, if it has one.
|
||||
fn grid(img: &Raster) -> Option<Grid> {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if w < 2 * SPAN || h < 2 * SPAN || img.rgba.len() != w * h * 4 {
|
||||
return None;
|
||||
}
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let luma = |p: &[u8; 4]| (p[3] == 255).then(|| f64::from(quantize::luma(*p)));
|
||||
let (mut across, mut down) = (([0.0; SPAN], [0u64; SPAN]), ([0.0; SPAN], [0u64; SPAN]));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let Some(here) = px.get(y * w + x).and_then(luma) else { continue };
|
||||
let add = |bins: &mut ([f64; SPAN], [u64; SPAN]), at: usize, other: Option<f64>| {
|
||||
if let Some(step) = other.map(|o| (here - o).abs()).filter(|&s| s < EDGE) {
|
||||
bins.0[at % SPAN] += step;
|
||||
bins.1[at % SPAN] += 1;
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
add(&mut across, x, px.get(y * w + x - 1).and_then(luma));
|
||||
}
|
||||
if y > 0 {
|
||||
add(&mut down, y, px.get((y - 1) * w + x).and_then(luma));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (across, extra_across, flat_across) = peak(&across.0, &across.1)?;
|
||||
let (down, extra_down, flat_down) = peak(&down.0, &down.1)?;
|
||||
// Blocks that are each one flat color, with no step at all inside them, are cells of a picture
|
||||
// enlarged eight times, which stay as they are.
|
||||
if flat_across && flat_down {
|
||||
return None;
|
||||
}
|
||||
Some(Grid { across, down, extra: extra_across.min(extra_down) })
|
||||
}
|
||||
|
||||
/// Smooths one stretch of a block edge in `band` (values in sixteenths of a level, 3 channels per
|
||||
/// pixel): `lines` (1 to 8) lines that cross the edge, the pixel at position `i` (0 to 7; the edge lies between
|
||||
/// 3 and 4) of line `l` starting at `band[index(l, i)]`, where `opaque(l, i)` says whether it is fully
|
||||
/// opaque. The stretch's step is the mean over its lines of the step across the edge less the slope
|
||||
/// on either side. A stretch of opaque pixels whose step is under `limit` on every channel, with no
|
||||
/// step of `strength.guard` or more between neighbors in its pixels (across, the edge itself aside, or
|
||||
/// along), has that step spread over the eight pixels of each line: with the line's own difference
|
||||
/// from it, to at most `strength.row_share` of `limit` either way, each pixel moves by the step times
|
||||
/// 3.5/8, 2.5/8, 1.5/8 or 0.5/8 by its distance from the edge, so the pixels in the middle of the
|
||||
/// blocks hardly move.
|
||||
fn smooth_edge(
|
||||
band: &mut [i16],
|
||||
lines: usize,
|
||||
index: impl Fn(usize, usize) -> usize,
|
||||
opaque: impl Fn(usize, usize) -> bool,
|
||||
limit: f64,
|
||||
strength: Strength,
|
||||
) {
|
||||
if lines == 0 || lines > BLOCK || !(0..lines).all(|l| (0..BLOCK).all(|i| opaque(l, i))) {
|
||||
return;
|
||||
}
|
||||
let at = |l: usize, i: usize, k: usize| f64::from(band.get(index(l, i) + k).copied().unwrap_or(0)) / SCALE;
|
||||
// Each line's step on each channel, and the largest step within the pixels around the edge.
|
||||
let mut steps = [[0.0; 3]; BLOCK];
|
||||
let steps = steps.get_mut(..lines).unwrap_or_default();
|
||||
let mut inner = 0.0f64;
|
||||
for (l, step) in steps.iter_mut().enumerate() {
|
||||
for (k, s) in step.iter_mut().enumerate() {
|
||||
let p: [f64; BLOCK] = std::array::from_fn(|i| at(l, i, k));
|
||||
*s = (p[4] - p[3]) - ((p[3] - p[2]) + (p[5] - p[4])) / 2.0;
|
||||
for i in (0..BLOCK - 1).filter(|&i| i != 3) {
|
||||
inner = inner.max((p[i + 1] - p[i]).abs());
|
||||
}
|
||||
// And from this line to the next: a line along the edge is an edge too.
|
||||
if l + 1 < lines {
|
||||
for (i, &here) in p.iter().enumerate() {
|
||||
inner = inner.max((at(l + 1, i, k) - here).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mean: [f64; 3] = std::array::from_fn(|k| steps.iter().map(|s| s[k]).sum::<f64>() / lines as f64);
|
||||
if inner >= strength.guard || mean.iter().any(|m| m.abs() >= limit) {
|
||||
return;
|
||||
}
|
||||
let own = limit * strength.row_share;
|
||||
for (l, step) in steps.iter().enumerate() {
|
||||
for k in 0..3 {
|
||||
let step = mean[k] + (step[k] - mean[k]).clamp(-own, own);
|
||||
for i in 0..BLOCK / 2 {
|
||||
let shift = (step * (3.5 - i as f64) / BLOCK as f64 * SCALE).round() as i16;
|
||||
if let Some(p) = band.get_mut(index(l, 3 - i) + k) {
|
||||
*p = p.saturating_add(shift);
|
||||
}
|
||||
if let Some(p) = band.get_mut(index(l, 4 + i) + k) {
|
||||
*p = p.saturating_sub(shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the stretches of eight lines start along a block edge `len` lines long, when they start at
|
||||
/// the lines with `l % 8 == at`: the first, from line 0, can be shorter. Each as (first line, count).
|
||||
fn stretches(len: usize, at: usize) -> Vec<(usize, usize)> {
|
||||
let mut out: Vec<(usize, usize)> = if at > 0 { vec![(0, at.min(len))] } else { vec![] };
|
||||
out.extend((at..len).step_by(BLOCK).map(|s| (s, BLOCK.min(len - s))));
|
||||
out
|
||||
}
|
||||
|
||||
/// `f` of each of `bands` (with what it needs to know about the band), which it may change, spread
|
||||
/// over the available cores.
|
||||
fn each_band<K: Copy + Send, T: Send>(bands: Vec<(K, &mut [T])>, f: impl Fn(K, &mut [T]) + Sync) {
|
||||
let bands: Vec<Mutex<(K, &mut [T])>> = bands.into_iter().map(Mutex::new).collect();
|
||||
parallel_map(&bands, |band| {
|
||||
// Each band is locked by one worker only, so the lock is never held elsewhere.
|
||||
if let Ok(mut band) = band.lock() {
|
||||
let (first, values) = &mut *band;
|
||||
f(*first, values);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The first block edge (of positions `at`, `at + 8`, ...) with four pixels before it.
|
||||
fn first_edge(at: usize) -> usize {
|
||||
if at >= BLOCK / 2 { at } else { at + BLOCK }
|
||||
}
|
||||
|
||||
/// Smooths the block edges of `v`, rows `top` on of the image (`w` pixels of 3 channels, in
|
||||
/// sixteenths of a level, from the first row of a stretch of rows to the last row of one), where
|
||||
/// `opaque(y, x)` is set: the edges between columns `x - 1` and `x` with `x % 8 == across` in stretches
|
||||
/// of eight rows that start at the rows with `y % 8 == down`, then the edges between rows `e - 1` and
|
||||
/// `e` for each `e` of `edges` in stretches of eight columns that start at the columns with
|
||||
/// `x % 8 == across` (see `smooth_edge`).
|
||||
fn smooth_strip(
|
||||
v: &mut [i16],
|
||||
top: usize,
|
||||
w: usize,
|
||||
grid: &Grid,
|
||||
edges: &[usize],
|
||||
opaque: impl Fn(usize, usize) -> bool,
|
||||
(limit, strength): (f64, Strength),
|
||||
) {
|
||||
let row = w * 3;
|
||||
if w < BLOCK || row == 0 {
|
||||
return;
|
||||
}
|
||||
for (at, rows) in stretches(v.len() / row, (grid.down + BLOCK - top % BLOCK) % BLOCK) {
|
||||
let Some(band) = v.get_mut(at * row..(at + rows) * row) else { continue };
|
||||
for e in (first_edge(grid.across)..=w - BLOCK / 2).step_by(BLOCK) {
|
||||
smooth_edge(band, rows, |l, i| (l * w + e - BLOCK / 2 + i) * 3, |l, i| opaque(top + at + l, e - BLOCK / 2 + i), limit, strength);
|
||||
}
|
||||
}
|
||||
for &e in edges {
|
||||
let Some(band) = e.checked_sub(top + BLOCK / 2).and_then(|at| v.get_mut(at * row..(at + BLOCK) * row)) else { continue };
|
||||
for (left, columns) in stretches(w, grid.across) {
|
||||
smooth_edge(band, columns, |l, i| (i * w + left + l) * 3, |l, i| opaque(e - BLOCK / 2 + i, left + l), limit, strength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges between rows smoothed in one piece of work, with the stretches of rows around them.
|
||||
const STRIP: usize = 8;
|
||||
|
||||
/// `img` with its block edges smoothed away as `strength` says, or `None` when it shows no block grid.
|
||||
/// The image is worked on in strips of rows, each with the stretches of rows its `STRIP` edges between
|
||||
/// rows need, so only the copy it returns is as large as the image.
|
||||
pub(crate) fn deblocked(img: &Raster, strength: Strength) -> Option<Raster> {
|
||||
let grid = grid(img)?;
|
||||
let limit = (strength.reach * grid.extra).clamp(LEAST, EDGE);
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let opaque = |y: usize, x: usize| px.get(y * w + x).is_some_and(|p| p[3] == 255);
|
||||
let edges: Vec<usize> = (first_edge(grid.down)..=h.saturating_sub(BLOCK / 2)).step_by(BLOCK).collect();
|
||||
let groups: Vec<&[usize]> = edges.chunks(STRIP).collect();
|
||||
// The rows each strip writes: from four rows above its first edge (the top of the image for the
|
||||
// first strip) to four rows below its last (the bottom of the image for the last strip).
|
||||
let mut rgba = img.rgba.clone();
|
||||
let mut bands = vec![];
|
||||
let mut rest: &mut [u8] = &mut rgba;
|
||||
let mut above = 0;
|
||||
for (n, group) in groups.iter().enumerate() {
|
||||
let below = if n + 1 == groups.len() { h } else { group.last().map_or(h, |e| e + BLOCK / 2) };
|
||||
let (band, after) = std::mem::take(&mut rest).split_at_mut_checked(below.checked_sub(above)? * w * 4)?;
|
||||
bands.push(((above, *group), band));
|
||||
rest = after;
|
||||
above = below;
|
||||
}
|
||||
// The first row of the stretch of rows that holds row `y`, and the row after the stretch.
|
||||
let stretch = |y: usize| {
|
||||
let first = if y < grid.down { 0 } else { y - (y - grid.down) % BLOCK };
|
||||
(first, if y < grid.down { grid.down } else { first + BLOCK }.min(h))
|
||||
};
|
||||
each_band(bands, |(above, group), band| {
|
||||
let below = above + band.len() / (w * 4);
|
||||
let (first, last) = (stretch(above).0, stretch(below.saturating_sub(1)).1);
|
||||
let Some(source) = px.get(first * w..last * w) else { return };
|
||||
let mut v: Vec<i16> = source.iter().flat_map(|p| [p[0], p[1], p[2]].map(|c| i16::from(c) * SCALE as i16)).collect();
|
||||
smooth_strip(&mut v, first, w, &grid, group, opaque, (limit, strength));
|
||||
let skip = (above - first) * w;
|
||||
for (p, c) in band.as_chunks_mut::<4>().0.iter_mut().zip(v.as_chunks::<3>().0.iter().skip(skip)) {
|
||||
for k in 0..3 {
|
||||
p[k] = ((i32::from(c[k]) + 8).div_euclid(16)).clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
Some(Raster { width: img.width, height: img.height, rgba })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A number in -1..1 that looks random, from `x`, `y` and `k`.
|
||||
fn jitter(x: u32, y: u32, k: u32) -> f64 {
|
||||
let mut n = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ k.wrapping_mul(0xC2B2_AE3D);
|
||||
n ^= n >> 15;
|
||||
n = n.wrapping_mul(0x2C1B_3C6D);
|
||||
n ^= n >> 12;
|
||||
f64::from(n % 2001) / 1000.0 - 1.0
|
||||
}
|
||||
|
||||
/// A dark ground that brightens slowly to the right, with a level of noise and, when `blocks`,
|
||||
/// every 8 x 8 block (counted from `(dx, dy)`) moved by up to 2 levels, as a JPEG leaves it.
|
||||
fn ground(w: u32, h: u32, blocks: bool, dx: u32, dy: u32) -> Raster {
|
||||
Raster::from_fn(w, h, |x, y| {
|
||||
let offset = if blocks { 2.0 * jitter((x + dx) / 8, (y + dy) / 8, 9) } else { 0.0 };
|
||||
let v = 20.0 + 30.0 * f64::from(x) / f64::from(w) + offset + jitter(x, y, 0);
|
||||
let v = v.round().clamp(0.0, 255.0) as u8;
|
||||
[v, v, v, 255]
|
||||
})
|
||||
}
|
||||
|
||||
/// The mean step between neighboring columns at the block edges (`x % 8 == across`) and elsewhere.
|
||||
fn steps(img: &Raster, across: u32) -> (f64, f64) {
|
||||
let (mut on, mut off) = ((0.0, 0), (0.0, 0));
|
||||
for y in 0..img.height {
|
||||
for x in 1..img.width {
|
||||
let d = (f64::from(img.pixel(x, y)[0]) - f64::from(img.pixel(x - 1, y)[0])).abs();
|
||||
let bin = if x % 8 == across { &mut on } else { &mut off };
|
||||
bin.0 += d;
|
||||
bin.1 += 1;
|
||||
}
|
||||
}
|
||||
(on.0 / f64::from(on.1), off.0 / f64::from(off.1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_block_grid_is_found_where_the_blocks_are() {
|
||||
let g = grid(&ground(160, 120, true, 0, 0)).unwrap();
|
||||
assert_eq!((g.across, g.down), (0, 0));
|
||||
// The same image with 3 columns and 5 rows cut off: its block edges are at x % 8 == 5, y % 8 == 3.
|
||||
let g = grid(&ground(160, 120, true, 3, 5)).unwrap();
|
||||
assert_eq!((g.across, g.down), (5, 3));
|
||||
assert!(g.extra > 0.5, "{}", g.extra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_without_blocks_is_left_as_it_is() {
|
||||
assert_eq!(grid(&ground(160, 120, false, 0, 0)), None);
|
||||
assert!(deblocked(&ground(160, 120, false, 0, 0), FEW_COLORS).is_none());
|
||||
// A picture of four flat colors enlarged eight times has steps only at its cells' edges, and
|
||||
// none within them; two of its colors are 6 levels apart.
|
||||
let colors = [[30, 30, 36, 255], [36, 30, 30, 255], [200, 60, 40, 255], [240, 220, 120, 255]];
|
||||
let cells = Raster::from_fn(160, 120, |x, y| colors[((jitter(x / 8, y / 8, 1) + 1.0) * 2.0).min(3.0) as usize]);
|
||||
assert_eq!(grid(&cells), None);
|
||||
// So does a picture of many shades enlarged eight times, as a photo's would.
|
||||
let shades = Raster::from_fn(160, 120, |x, y| {
|
||||
let v = (40.0 + 0.6 * f64::from(x / 8 * 8) + 0.5 * f64::from(y / 8 * 8) + 3.0 * jitter(x / 8, y / 8, 4)).round() as u8;
|
||||
[v, v.saturating_sub(10), v.saturating_add(10), 255]
|
||||
});
|
||||
assert_eq!(grid(&shades), None);
|
||||
// A noisy checkerboard of 16 px squares 10 levels apart has its steps on every other block edge.
|
||||
let squares = Raster::from_fn(160, 120, |x, y| {
|
||||
let v = (20.0 + 10.0 * f64::from((x / 16 + y / 16) % 3) + 4.0 * jitter(x, y, 2)).round() as u8;
|
||||
[v, v, v, 255]
|
||||
});
|
||||
assert_eq!(grid(&squares), None);
|
||||
// Too small to hold two spans of 16 px each way.
|
||||
assert_eq!(grid(&ground(31, 120, true, 0, 0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_steps_at_block_edges_become_ramps() {
|
||||
for ((dx, dy), strength) in [((0, 0), FEW_COLORS), ((3, 5), FEW_COLORS), ((0, 0), MANY_COLORS), ((0, 0), BLACK_AND_WHITE)] {
|
||||
let img = ground(160, 120, true, dx, dy);
|
||||
let across = (8 - dx % 8) % 8;
|
||||
let (on, off) = steps(&img, across);
|
||||
assert!(on > 2.0 * off, "before: {on} at the block edges, {off} elsewhere");
|
||||
let out = deblocked(&img, strength).unwrap();
|
||||
let (on, off) = steps(&out, across);
|
||||
assert!(on < 1.25 * off, "after: {on} at the block edges, {off} elsewhere");
|
||||
// The ground's own slope stays: 30 levels across the image.
|
||||
let mean = |r: &Raster, x: u32| (0..r.height).map(|y| f64::from(r.pixel(x, y)[0])).sum::<f64>() / f64::from(r.height);
|
||||
assert!((mean(&out, 156) - mean(&out, 4) - (mean(&img, 156) - mean(&img, 4))).abs() < 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretches_cover_every_line_once() {
|
||||
assert_eq!(stretches(20, 0), [(0, 8), (8, 8), (16, 4)]);
|
||||
assert_eq!(stretches(20, 3), [(0, 3), (3, 8), (11, 8), (19, 1)]);
|
||||
assert_eq!(stretches(2, 3), [(0, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_many_colors_every_row_of_a_stretch_moves_alike() {
|
||||
let img = ground(160, 120, true, 0, 0);
|
||||
let v: Vec<i16> = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]].map(|c| i16::from(c) * SCALE as i16)).collect();
|
||||
// How far each pixel moves when the edges between columns are smoothed.
|
||||
let shifts = |strength: Strength| -> Vec<i16> {
|
||||
let mut out = v.clone();
|
||||
for top in (0..120).step_by(8) {
|
||||
let band = &mut out[top * 160 * 3..(top + 8) * 160 * 3];
|
||||
for e in (8..=156).step_by(8) {
|
||||
smooth_edge(band, 8, |l, i| (l * 160 + e - 4 + i) * 3, |_, _| true, 4.0, strength);
|
||||
}
|
||||
}
|
||||
out.iter().zip(&v).map(|(o, i)| o - i).step_by(3).collect()
|
||||
};
|
||||
let (many, few) = (shifts(MANY_COLORS), shifts(FEW_COLORS));
|
||||
let differ = |s: &[i16]| {
|
||||
(0..160)
|
||||
.flat_map(|x| (0..120).step_by(8).map(move |top| (x, top)))
|
||||
.filter(|&(x, top)| (top..top + 8).any(|y| s[y * 160 + x] != s[top * 160 + x]))
|
||||
.count()
|
||||
};
|
||||
assert!(many.iter().filter(|&&s| s != 0).count() > 160 * 120 / 2);
|
||||
assert_eq!(differ(&many), 0);
|
||||
// With few colors, each row keeps part of its own step.
|
||||
assert!(differ(&few) > 160 * 15 / 2, "{}", differ(&few));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_and_fine_lines_stay_sharp() {
|
||||
// Black lines 1 to 4 px wide and white gaps between them on a block-coded mid-gray ground, one
|
||||
// line on a block edge.
|
||||
let base = ground(160, 120, true, 0, 0);
|
||||
let lines = [(16, 1), (31, 2), (40, 3), (61, 4)];
|
||||
let img = Raster::from_fn(160, 120, |x, y| {
|
||||
if lines.iter().any(|&(at, wide)| (at..at + wide).contains(&x)) {
|
||||
[0, 0, 0, 255]
|
||||
} else {
|
||||
let v = base.pixel(x, y)[0] + 80;
|
||||
[v, v, v, 255]
|
||||
}
|
||||
});
|
||||
let out = deblocked(&img, FEW_COLORS).unwrap();
|
||||
for y in 0..120 {
|
||||
for &(at, wide) in &lines {
|
||||
for x in at..at + wide {
|
||||
assert_eq!(out.pixel(x, y), [0, 0, 0, 255], "line pixel ({x}, {y})");
|
||||
}
|
||||
// The ground beside a line moves by no more than a block's offset (the edges between
|
||||
// rows in its column are smoothed; the one across the line is not).
|
||||
for x in [at - 1, at + wide] {
|
||||
let moved = (i32::from(out.pixel(x, y)[0]) - i32::from(img.pixel(x, y)[0])).abs();
|
||||
assert!(moved <= 3, "beside the line, ({x}, {y}) moved {moved}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_pixels_keep_their_colors() {
|
||||
let base = ground(160, 120, true, 0, 0);
|
||||
let img = Raster::from_fn(160, 120, |x, y| {
|
||||
let mut p = base.pixel(x, y);
|
||||
if x >= 100 {
|
||||
p[3] = if x < 104 { 128 } else { 0 };
|
||||
}
|
||||
p
|
||||
});
|
||||
let out = deblocked(&img, FEW_COLORS).unwrap();
|
||||
for y in 0..120 {
|
||||
for x in 100..160 {
|
||||
assert_eq!(out.pixel(x, y), img.pixel(x, y), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
assert_eq!(out.rgba.chunks(4).map(|p| p[3]).collect::<Vec<_>>(), img.rgba.chunks(4).map(|p| p[3]).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
mod centerline;
|
||||
mod contour;
|
||||
mod deblock;
|
||||
mod fit;
|
||||
mod gradient;
|
||||
mod group;
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use super::layers::{Layers, parallel_map_at_most};
|
||||
use super::params::{Method, Mode, TraceParams};
|
||||
use super::raster::{Raster, TraceError};
|
||||
use super::{AtomicUsize, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, gradient, group, logo, quantize, smooth};
|
||||
use super::{AtomicUsize, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, deblock, gradient, group, logo, quantize, smooth};
|
||||
|
||||
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
|
||||
/// lines stroked with it.
|
||||
@@ -209,9 +209,21 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &(
|
||||
Some(name) if params.ignore_white => Some(logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))?),
|
||||
_ => None,
|
||||
};
|
||||
// A photo is traced from a copy with its noise smoothed away (see `smooth`).
|
||||
let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img));
|
||||
let img = smoothed.as_ref().unwrap_or(img);
|
||||
// A JPEG's 8 x 8 block edges are smoothed away first (see `deblock`).
|
||||
let strength = match params.mode {
|
||||
Mode::BlackAndWhite => deblock::BLACK_AND_WHITE,
|
||||
_ if params.colors > 16 => deblock::MANY_COLORS,
|
||||
_ => deblock::FEW_COLORS,
|
||||
};
|
||||
// A photo is traced from a copy with its noise smoothed away (see `smooth`); the deblocked copy is
|
||||
// dropped once it is smoothed.
|
||||
let photo = |img: &Raster| params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img);
|
||||
let prepared = match deblock::deblocked(img, strength) {
|
||||
Some(deblocked) if photo(&deblocked) => Some(smooth::smoothed(&deblocked)),
|
||||
Some(deblocked) => Some(deblocked),
|
||||
None => photo(img).then(|| smooth::smoothed(img)),
|
||||
};
|
||||
let img = prepared.as_ref().unwrap_or(img);
|
||||
go_on()?;
|
||||
// Gradients are found first and kept out of the palette: no colour is spent on bands of them.
|
||||
// Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray.
|
||||
|
||||
@@ -2111,6 +2111,74 @@ fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` saved as a JPEG of `quality` and read back.
|
||||
fn through_jpeg(img: &Raster, quality: u8) -> Raster {
|
||||
let rgb: Vec<u8> = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let mut bytes = vec![];
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality)
|
||||
.encode(&rgb, img.width, img.height, image::ExtendedColorType::Rgb8)
|
||||
.unwrap();
|
||||
Raster::decode(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// The straight segments of `res`' paths that run across or down, at least 2 px long: for each, whether
|
||||
/// it runs down, its column (down) or row (across), its length, and whether it is a step's riser, the
|
||||
/// segments before and after it both running the other way and turning the path the same way.
|
||||
fn axis_segments(res: &TraceResult) -> Vec<(bool, f64, f64, bool)> {
|
||||
let mut out = vec![];
|
||||
for sp in res.paths.iter().flat_map(|p| &p.path.subpaths) {
|
||||
let n = sp.anchors.len();
|
||||
let seg = |i: usize| {
|
||||
let (a, b) = (&sp.anchors[i % n], &sp.anchors[(i + 1) % n]);
|
||||
let straight = a.h_out.distance(a.p) < 1e-6 && b.h_in.distance(b.p) < 1e-6;
|
||||
let (dx, dy) = (b.p.x - a.p.x, b.p.y - a.p.y);
|
||||
// 1 across, 2 down, 0 neither; and the direction along it.
|
||||
let axis = if !straight || dx.hypot(dy) < 2.0 {
|
||||
0
|
||||
} else if dy.abs() < 0.05 {
|
||||
1
|
||||
} else if dx.abs() < 0.05 {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(axis, dx.signum(), dy.signum(), dx.hypot(dy), a.p)
|
||||
};
|
||||
let last = if sp.closed { n } else { n.saturating_sub(1) };
|
||||
for i in 0..last {
|
||||
let (axis, _, _, len, p) = seg(i);
|
||||
if axis == 0 {
|
||||
continue;
|
||||
}
|
||||
let (before, after) = (seg(i + n - 1), seg(i + 1));
|
||||
let riser = sp.closed && before.0 != 0 && before.0 != axis && after.0 == before.0 && before.1 == after.1 && before.2 == after.2;
|
||||
out.push((axis == 2, if axis == 2 { p.x } else { p.y }, len, riser));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpegs_blocks_are_not_traced_as_steps() {
|
||||
// A dark noisy ground whose tone changes slowly across and down, saved as a JPEG of quality 40.
|
||||
let img = through_jpeg(
|
||||
&Raster::from_fn(320, 240, |x, y| {
|
||||
let v = 14.0 + 22.0 * f64::from(x) / 320.0 + 14.0 * f64::from(y) / 240.0;
|
||||
let c = [0.9 * v, v, 0.8 * v].map(|c| (c + 8.0 * jitter(x, y, 3)).round().clamp(0.0, 255.0) as u8);
|
||||
[c[0], c[1], c[2], 255]
|
||||
}),
|
||||
40,
|
||||
);
|
||||
let res = trace(&img, &preset("16 Colors").unwrap());
|
||||
// The image's border is left out.
|
||||
let segments: Vec<_> = axis_segments(&res).into_iter().filter(|s| s.1 > 0.6 && s.1 < if s.0 { 319.4 } else { 239.4 }).collect();
|
||||
let on_grid: Vec<_> = segments.iter().filter(|s| (s.1 / 8.0 - (s.1 / 8.0).round()).abs() * 8.0 < 0.6).collect();
|
||||
// By chance, 15 in 100 segments are within 0.6 px of a block edge.
|
||||
assert!(on_grid.len() * 4 < segments.len().max(1), "{} of {} straight segments on the block edges: {segments:?}", on_grid.len(), segments.len());
|
||||
let risers: Vec<_> = on_grid.iter().filter(|s| s.3 && s.2 >= 4.0).collect();
|
||||
assert!(risers.is_empty(), "risers of 4 px or more on the block edges: {risers:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tinted_flat_art_stays_flat_art() {
|
||||
let tints: [(&str, &[[f64; 3]]); 4] = [("gray", &GRAY_RAMP), ("sepia", &SEPIA), ("blue", &BLUE), ("duotone", &DUOTONE)];
|
||||
|
||||
@@ -73,8 +73,8 @@ pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool {
|
||||
/// A basic shape selected with the Selection tool shows a Shape Properties button in place of Lock.
|
||||
/// Selected type also shows the font family, font style and Font Size controls before its Fill.
|
||||
/// While a Type tool edits type, or has type selected, the bar shows only those controls and the
|
||||
/// Fill. The font controls take a second row when one row would be wider than the canvas, and
|
||||
/// narrow there to fit it.
|
||||
/// Fill. When one row would be wider than the canvas, the font controls take a row of their own,
|
||||
/// narrowed to fit it, and the actions wrap onto rows under the handle's ([`bar_layout`]).
|
||||
/// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the
|
||||
/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]).
|
||||
pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
@@ -231,7 +231,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
}
|
||||
items.push((tl!("Duplicate"), "copy", "edit.duplicate", none()));
|
||||
}
|
||||
let (fill, stroke, fill_mixed, stroke_mixed) = chip_paints(app, ui.ctx());
|
||||
let paints = chip_paints(app, ui.ctx());
|
||||
let colored = has_colors(&st.doc, &st.selection.objects);
|
||||
let doc = st.uid;
|
||||
// Under the selection's lowest corner on screen, centered on the selection (the view may be
|
||||
@@ -239,23 +239,47 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
|
||||
let anchor = pos2(xf.to_screen(b.center()).x, lowest);
|
||||
let bounds = xf.rect.shrink(8.0);
|
||||
// The canvas's width less the frame's margins and stroke.
|
||||
let room = bounds.width() - BAR_FRAME;
|
||||
// On a canvas too narrow for the type controls at their narrowest, the bar leaves them out.
|
||||
let fonts = fonts && bounds.width() - 18.0 >= crate::panels::character::task_bar_min_room();
|
||||
let fonts = fonts && room >= crate::panels::character::task_bar_min_room();
|
||||
// The selected objects' Fill, Stroke, Recolor Artwork (when they have colours to recolour) and
|
||||
// Lock (a basic shape's properties instead), left out with the Artboard tool. A Type tool's bar
|
||||
// has the Fill alone.
|
||||
let chips: Vec<Chip> = [
|
||||
(!on_artboards, Chip::Fill),
|
||||
(!on_artboards && !typing, Chip::Stroke),
|
||||
(!on_artboards && !typing && colored, Chip::Recolor),
|
||||
(!on_artboards && !typing, Chip::Lock),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(shown, chip)| shown.then_some(chip))
|
||||
.collect();
|
||||
let widths: Vec<f32> = items.iter().map(|(label, ..)| action_width(ui, label)).collect();
|
||||
let font_w = fonts.then(|| {
|
||||
let w = crate::panels::character::TASK_BAR_WIDTHS;
|
||||
w.iter().sum::<f32>() + BAR_GAP * (w.len() - 1) as f32
|
||||
});
|
||||
let spacing = ui.spacing().item_spacing.y;
|
||||
let chip_widths: Vec<f32> = chips.iter().map(|c| c.width()).collect();
|
||||
let layout = bar_layout(&widths, &chip_widths, font_w, room, bar_max_rows(bounds.height(), spacing));
|
||||
// On a canvas too small for its handle and More Options: no bar, rather than one that runs off
|
||||
// the canvas.
|
||||
let fonts = layout.as_ref().is_some_and(|l| l.fonts);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(task_bar_id().with("fonts"), fonts));
|
||||
// The width of the bar's frame (margins and stroke), its handle, Fill and Stroke chips,
|
||||
// Recolor Artwork, Lock or Shape Properties, and More Options, each after a 6-point gap.
|
||||
let chips: f32 =
|
||||
[(!on_artboards, 26.0), (!on_artboards && !typing, 26.0), (!on_artboards && !typing && colored, 30.0), (!on_artboards && !typing, 30.0)]
|
||||
.iter()
|
||||
.filter(|(shown, _)| *shown)
|
||||
.map(|(_, w)| w + 6.0)
|
||||
.sum();
|
||||
let (font_row, est) = size_estimate(&items, fonts, 18.0 + 4.0 + chips + 36.0, bounds.width());
|
||||
let Some(layout) = layout else { return };
|
||||
let est = layout.size(spacing);
|
||||
// Its place under the selection, then where the handle moved it, kept on the canvas (with the
|
||||
// bar's size last frame, the estimate before it first shows).
|
||||
// bar's size last frame, the layout's size before it first shows and whenever the layout
|
||||
// changed since, as after the canvas or the selection changed).
|
||||
let under = pos2(anchor.x - est.x / 2.0, anchor.y + 28.0);
|
||||
let id = task_bar_id();
|
||||
let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(est);
|
||||
let unchanged = ui.ctx().data_mut(|d| {
|
||||
let last = d.get_temp::<Vec2>(id.with("layout"));
|
||||
d.insert_temp(id.with("layout"), est);
|
||||
last == Some(est)
|
||||
});
|
||||
let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).filter(|_| unchanged).unwrap_or(est);
|
||||
let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min));
|
||||
let place = &mut app.ui.task_bar_place;
|
||||
// Just unpinned: follow the selection from the pinned spot.
|
||||
@@ -285,95 +309,88 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) })
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let (g, grip) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::drag());
|
||||
let active = grip.hovered() || grip.dragged();
|
||||
ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), if active { t.text_dim } else { t.button_border });
|
||||
if grip.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
moved = grip.drag_delta();
|
||||
} else if grip.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
for (label, icon, cmd, params) in &items {
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside);
|
||||
crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon);
|
||||
ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong);
|
||||
if resp.clicked() {
|
||||
run = Some((*cmd, params.clone()));
|
||||
}
|
||||
}
|
||||
// Type's font family, font style and Font Size, which act as the Character
|
||||
// panel's do: on the characters a Type tool selects, else on the selected type.
|
||||
if fonts && !font_row {
|
||||
crate::panels::character::task_bar(app, ui, f32::INFINITY);
|
||||
}
|
||||
// The selected objects' Fill, Stroke, Recolor Artwork and Lock (a basic shape's
|
||||
// properties instead), left out with the Artboard tool. A Type tool's bar has
|
||||
// the Fill alone.
|
||||
if !on_artboards && paint_chip(ui, &t, &fill, fill_mixed, None, tl!("Fill")) {
|
||||
app.session.fill_active = true;
|
||||
show_swatches = true;
|
||||
}
|
||||
if !on_artboards && !typing {
|
||||
// The Stroke chip, drawn as a ring as the Control bar's is.
|
||||
if paint_chip(ui, &t, &stroke, stroke_mixed, Some(4.0), tl!("Stroke")) {
|
||||
app.session.fill_active = false;
|
||||
show_swatches = true;
|
||||
}
|
||||
// Recolor Artwork, when the selection has colours to recolour.
|
||||
if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() {
|
||||
run = Some(("ui.recolorDialog", json!({})));
|
||||
}
|
||||
if let Some(shape) = &shape
|
||||
&& let NodeKind::Path { live: Some(live), .. } = &shape.kind
|
||||
{
|
||||
// The shape's properties in a popover that stays open while its fields and
|
||||
// its Corner dropdown are used.
|
||||
let props = widgets::icon_button(ui, "shapes", tl!("Shape Properties"), false, 30.0);
|
||||
widgets::popover(&props, props.clicked(), |ui| {
|
||||
ui.set_min_width(250.0);
|
||||
crate::panels::transform::live_shape_rows(app, ui, shape, live, "task-bar-shape", true);
|
||||
});
|
||||
} else {
|
||||
// The Lock button is off in isolation mode, as Object › Lock › Selection is.
|
||||
let lockable = crate::menus::enabled(app, "object.lock");
|
||||
if widgets::icon_button_enabled(ui, "lock", tl!("Lock (⌘2)"), false, lockable, 30.0).clicked() {
|
||||
run = Some(("object.lock", json!({})));
|
||||
}
|
||||
}
|
||||
}
|
||||
let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 30.0);
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
ui.set_min_width(170.0);
|
||||
let bar = [
|
||||
(tl!("Hide Bar"), "window.taskBar", false),
|
||||
(tl!("Pin Bar Position"), "window.taskBar.pin", pinned),
|
||||
(tl!("Reset Bar Position"), "window.taskBar.reset", false),
|
||||
];
|
||||
for (label, cmd, checked) in bar {
|
||||
if widgets::menu_item(ui, label, true, checked) {
|
||||
run = Some((cmd, json!({})));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if widgets::menu_item(ui, tl!("Show Properties Panel"), true, false) {
|
||||
run = Some(("window.panel", json!({"panel": "properties"})));
|
||||
}
|
||||
});
|
||||
});
|
||||
if font_row {
|
||||
// The first row holds the handle and More Options; actions that don't fit beside
|
||||
// them go on the rows after it, in order.
|
||||
for row in 0..layout.rows {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
// The canvas's width less the frame's margins and stroke.
|
||||
crate::panels::character::task_bar(app, ui, bounds.width() - 18.0);
|
||||
ui.spacing_mut().item_spacing.x = BAR_GAP;
|
||||
if row == 0 {
|
||||
let (g, grip) = ui.allocate_exact_size(vec2(BAR_HANDLE, 28.0), Sense::drag());
|
||||
let active = grip.hovered() || grip.dragged();
|
||||
ui.painter().rect_filled(
|
||||
g.shrink2(vec2(0.5, 4.0)),
|
||||
CornerRadius::same(2),
|
||||
if active { t.text_dim } else { t.button_border },
|
||||
);
|
||||
if grip.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
moved = grip.drag_delta();
|
||||
} else if grip.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
for ((label, icon, cmd, params), at) in items.iter().zip(&layout.actions) {
|
||||
if *at == Some(row) && action_button(ui, &t, label, icon) {
|
||||
run = Some((*cmd, params.clone()));
|
||||
}
|
||||
}
|
||||
// Type's font family, font style and Font Size, which act as the Character
|
||||
// panel's do: on the characters a Type tool selects, else on the selected type.
|
||||
if row == 0 && fonts && !layout.font_row {
|
||||
crate::panels::character::task_bar(app, ui, f32::INFINITY);
|
||||
}
|
||||
for (chip, at) in chips.iter().zip(&layout.chips) {
|
||||
if *at != row {
|
||||
continue;
|
||||
}
|
||||
match paint_or_object_chip(app, ui, &t, *chip, &paints, shape.as_ref()) {
|
||||
Some(ChipClick::Fill(fill)) => {
|
||||
app.session.fill_active = fill;
|
||||
show_swatches = true;
|
||||
}
|
||||
Some(ChipClick::Run(cmd)) => run = Some((cmd, json!({}))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if row != 0 {
|
||||
return;
|
||||
}
|
||||
let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, BAR_MORE);
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
ui.set_min_width(170.0);
|
||||
// The actions too wide for any row of the bar.
|
||||
let listed = items.iter().zip(&layout.actions).filter(|(_, at)| at.is_none());
|
||||
let mut any = false;
|
||||
for ((label, _, cmd, params), _) in listed {
|
||||
any = true;
|
||||
if widgets::menu_item_name(ui, label, true, false) {
|
||||
run = Some((*cmd, params.clone()));
|
||||
}
|
||||
}
|
||||
if any {
|
||||
ui.separator();
|
||||
}
|
||||
let bar = [
|
||||
(tl!("Hide Bar"), "window.taskBar", false),
|
||||
(tl!("Pin Bar Position"), "window.taskBar.pin", pinned),
|
||||
(tl!("Reset Bar Position"), "window.taskBar.reset", false),
|
||||
];
|
||||
for (label, cmd, checked) in bar {
|
||||
if widgets::menu_item(ui, label, true, checked) {
|
||||
run = Some((cmd, json!({})));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if widgets::menu_item(ui, tl!("Show Properties Panel"), true, false) {
|
||||
run = Some(("window.panel", json!({"panel": "properties"})));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if layout.font_row {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = BAR_GAP;
|
||||
crate::panels::character::task_bar(app, ui, room);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -413,14 +430,201 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the type controls go on a second row, and the bar's estimated size before it first
|
||||
/// shows: `items` and, when `fonts`, the font family, font style and Font Size controls, which take
|
||||
/// a second row when one row, with the bar's other controls `fixed` points wide, would be wider than
|
||||
/// `room` (and narrow to fit it there).
|
||||
fn size_estimate(items: &[(&str, &str, &str, serde_json::Value)], fonts: bool, fixed: f32, room: f32) -> (bool, Vec2) {
|
||||
let font_w: f32 = crate::panels::character::TASK_BAR_WIDTHS.iter().filter(|_| fonts).map(|w| w + 6.0).sum();
|
||||
let items_w = if fonts { fixed } else { 150.0 } + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
|
||||
if fonts && items_w + font_w > room { (true, vec2(items_w.max((font_w + 16.0).min(room)), 80.0)) } else { (false, vec2(items_w + font_w, 44.0)) }
|
||||
/// The width of the Contextual Task Bar's frame: its margins and stroke on both sides.
|
||||
const BAR_FRAME: f32 = 18.0;
|
||||
/// The gap between the bar's controls.
|
||||
const BAR_GAP: f32 = 6.0;
|
||||
/// The width of the bar's handle.
|
||||
const BAR_HANDLE: f32 = 4.0;
|
||||
/// The width of the bar's More Options button.
|
||||
const BAR_MORE: f32 = 30.0;
|
||||
/// The height of a row of the bar's controls.
|
||||
const BAR_ROW: f32 = 30.0;
|
||||
|
||||
/// The controls the Contextual Task Bar shows after its actions: the Fill and Stroke chips,
|
||||
/// Recolor Artwork, and Lock or a basic shape's Shape Properties.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Chip {
|
||||
Fill,
|
||||
Stroke,
|
||||
Recolor,
|
||||
Lock,
|
||||
}
|
||||
|
||||
impl Chip {
|
||||
fn width(self) -> f32 {
|
||||
match self {
|
||||
Chip::Fill | Chip::Stroke => 26.0,
|
||||
Chip::Recolor | Chip::Lock => 30.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click on one of the bar's [`Chip`]s does: make the Fill (`true`) or the Stroke active and
|
||||
/// bring up Swatches, or run a command.
|
||||
enum ChipClick {
|
||||
Fill(bool),
|
||||
Run(&'static str),
|
||||
}
|
||||
|
||||
/// Where each of the Contextual Task Bar's controls goes.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct BarLayout {
|
||||
/// Each action's row (0 is the row with the handle and More Options), or `None` for an action
|
||||
/// wider than a row or past the rows the canvas's height allows, which More Options lists
|
||||
/// instead.
|
||||
actions: Vec<Option<usize>>,
|
||||
/// Each chip's row.
|
||||
chips: Vec<usize>,
|
||||
/// Whether the bar shows the font controls it was given.
|
||||
fonts: bool,
|
||||
/// Whether the font controls take a row of their own, after the others.
|
||||
font_row: bool,
|
||||
/// The number of rows before the font controls' row.
|
||||
rows: usize,
|
||||
/// The bar's width, its frame included.
|
||||
width: f32,
|
||||
}
|
||||
|
||||
impl BarLayout {
|
||||
/// The bar's size, with `spacing` points between its rows.
|
||||
fn size(&self, spacing: f32) -> Vec2 {
|
||||
let rows = (self.rows + usize::from(self.font_row)) as f32;
|
||||
vec2(self.width, rows * BAR_ROW + (rows - 1.0) * spacing + 14.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// How many rows of the Contextual Task Bar fit in `height` points, with `spacing` points between
|
||||
/// them.
|
||||
fn bar_max_rows(height: f32, spacing: f32) -> usize {
|
||||
((height - 14.0 + spacing) / (BAR_ROW + spacing)).floor().max(0.0) as usize
|
||||
}
|
||||
|
||||
/// The Contextual Task Bar's layout in `room` points (the canvas's width less the frame) and at
|
||||
/// most `max_rows` rows, for actions and chips the given widths wide, and the font controls `fonts`
|
||||
/// wide when shown. One row holds everything when it fits. Otherwise the font controls take a row
|
||||
/// of their own, narrowed to the room, and the actions wrap: the first row keeps the handle, the
|
||||
/// chips and More Options, and each action goes on the last row when it fits there, else on a new
|
||||
/// row. When the handle, the chips and More Options don't fit in one row, the chips take rows of
|
||||
/// their own after the actions. An action wider than a row, or one that would need a row past
|
||||
/// `max_rows`, goes in More Options; without a row to spare the font controls are left out. `None`
|
||||
/// when not even the handle and More Options, and the chips' rows, fit.
|
||||
fn bar_layout(actions: &[f32], chips: &[f32], fonts: Option<f32>, room: f32, max_rows: usize) -> Option<BarLayout> {
|
||||
// Puts a control `w` wide on the last row when it fits there, else on a new row.
|
||||
fn place(rows: &mut Vec<f32>, w: f32, room: f32) -> usize {
|
||||
match rows.last_mut() {
|
||||
Some(last) if *last + BAR_GAP + w <= room => *last += BAR_GAP + w,
|
||||
_ => rows.push(w),
|
||||
}
|
||||
rows.len() - 1
|
||||
}
|
||||
let ends = BAR_HANDLE + BAR_GAP + BAR_MORE;
|
||||
if room < ends || max_rows == 0 {
|
||||
return None;
|
||||
}
|
||||
let chips_w: f32 = chips.iter().map(|w| w + BAR_GAP).sum();
|
||||
let actions_w: f32 = actions.iter().map(|w| w + BAR_GAP).sum();
|
||||
let one_row = ends + chips_w + actions_w + fonts.map_or(0.0, |w| w + BAR_GAP);
|
||||
if one_row <= room {
|
||||
return Some(BarLayout {
|
||||
actions: vec![Some(0); actions.len()],
|
||||
chips: vec![0; chips.len()],
|
||||
fonts: fonts.is_some(),
|
||||
font_row: false,
|
||||
rows: 1,
|
||||
width: one_row + BAR_FRAME,
|
||||
});
|
||||
}
|
||||
let chips_first = ends + chips_w <= room;
|
||||
let mut chip_rows = vec![];
|
||||
let chips: Vec<usize> = chips.iter().map(|w| if chips_first { 0 } else { place(&mut chip_rows, *w, room) }).collect();
|
||||
if 1 + chip_rows.len() > max_rows {
|
||||
return None;
|
||||
}
|
||||
let font_row = fonts.is_some() && 1 + chip_rows.len() < max_rows;
|
||||
let action_rows = max_rows - chip_rows.len() - usize::from(font_row);
|
||||
let mut rows = vec![if chips_first { ends + chips_w } else { ends }];
|
||||
// Once an action needs a row past the last one, it and the actions after it go in More Options,
|
||||
// keeping their order.
|
||||
let mut full = false;
|
||||
let actions: Vec<Option<usize>> = actions
|
||||
.iter()
|
||||
.map(|w| {
|
||||
if *w > room || full {
|
||||
return None;
|
||||
}
|
||||
let mut next = rows.clone();
|
||||
let row = place(&mut next, *w, room);
|
||||
full = row >= action_rows;
|
||||
(!full).then(|| {
|
||||
rows = next;
|
||||
row
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let chips = chips.into_iter().map(|row| if chips_first { 0 } else { rows.len() + row }).collect();
|
||||
let font_w = if font_row { fonts.map_or(0.0, |w| w.min(room)) } else { 0.0 };
|
||||
let width = rows.iter().chain(&chip_rows).fold(font_w, |a, b| a.max(*b)) + BAR_FRAME;
|
||||
Some(BarLayout { actions, chips, fonts: font_row, font_row, rows: rows.len() + chip_rows.len(), width })
|
||||
}
|
||||
|
||||
/// The width of the Contextual Task Bar's action button for `label`.
|
||||
fn action_width(ui: &Ui, label: &str) -> f32 {
|
||||
ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), Color32::PLACEHOLDER).size().x + 38.0
|
||||
}
|
||||
|
||||
/// One of the Contextual Task Bar's action buttons: `icon` and `label`, named for screen readers.
|
||||
/// Returns whether it was clicked.
|
||||
fn action_button(ui: &mut Ui, t: &Tokens, label: &str, icon: &str) -> bool {
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, BAR_ROW), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside);
|
||||
crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon);
|
||||
ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// One of the Contextual Task Bar's chips, with the Fill and Stroke `paints` ([`chip_paints`]) and
|
||||
/// the basic `shape` whose properties take Lock's place. Returns what a click on it does.
|
||||
fn paint_or_object_chip(
|
||||
app: &mut VectorcraftApp,
|
||||
ui: &mut Ui,
|
||||
t: &Tokens,
|
||||
chip: Chip,
|
||||
paints: &(vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool),
|
||||
shape: Option<&vectorcraft_doc::Node>,
|
||||
) -> Option<ChipClick> {
|
||||
let (fill, stroke, fill_mixed, stroke_mixed) = paints;
|
||||
match chip {
|
||||
Chip::Fill => paint_chip(ui, t, fill, *fill_mixed, None, tl!("Fill")).then_some(ChipClick::Fill(true)),
|
||||
// The Stroke chip, drawn as a ring as the Control bar's is.
|
||||
Chip::Stroke => paint_chip(ui, t, stroke, *stroke_mixed, Some(4.0), tl!("Stroke")).then_some(ChipClick::Fill(false)),
|
||||
Chip::Recolor => {
|
||||
widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked().then_some(ChipClick::Run("ui.recolorDialog"))
|
||||
}
|
||||
Chip::Lock => {
|
||||
if let Some(shape) = shape
|
||||
&& let NodeKind::Path { live: Some(live), .. } = &shape.kind
|
||||
{
|
||||
// The shape's properties in a popover that stays open while its fields and its
|
||||
// Corner dropdown are used.
|
||||
let props = widgets::icon_button(ui, "shapes", tl!("Shape Properties"), false, 30.0);
|
||||
widgets::popover(&props, props.clicked(), |ui| {
|
||||
ui.set_min_width(250.0);
|
||||
crate::panels::transform::live_shape_rows(app, ui, shape, live, "task-bar-shape", true);
|
||||
});
|
||||
None
|
||||
} else {
|
||||
// The Lock button is off in isolation mode, as Object › Lock › Selection is.
|
||||
let lockable = crate::menus::enabled(app, "object.lock");
|
||||
widgets::icon_button_enabled(ui, "lock", tl!("Lock (⌘2)"), false, lockable, 30.0).clicked().then_some(ChipClick::Run("object.lock"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or
|
||||
|
||||
@@ -680,9 +680,17 @@ fn bucket_modifier_only_hover_updates_the_visible_cursor() {
|
||||
frame(app, &ctx, events);
|
||||
app.canvas.hover_cursor.map(|(_, _, cursor)| cursor)
|
||||
};
|
||||
// The cursor swatch preview's chips go and come back with Alt too.
|
||||
let chips = |app: &mut VectorcraftApp| {
|
||||
let view = app.view_info();
|
||||
app.session.overlays(view).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Swatch { .. })).count()
|
||||
};
|
||||
assert_eq!(shown(&mut app, vec![Event::PointerMoved(at)]), Some(Cursor::LivePaintBucket));
|
||||
assert!(chips(&mut app) > 0);
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT)]), Some(Cursor::Eyedropper));
|
||||
assert_eq!(chips(&mut app), 0);
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]), Some(Cursor::LivePaintBucket));
|
||||
assert!(chips(&mut app) > 0);
|
||||
}
|
||||
|
||||
/// The Image Trace panel's eye-button hold ends when the pointer is released, even when the
|
||||
|
||||
@@ -947,12 +947,6 @@ fn names_a_loaded_library(v: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog.
|
||||
const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The scratch disk choices that are not paths.
|
||||
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||||
|
||||
/// May the request being served read `path`? Under an automation read root (a control client's
|
||||
/// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent
|
||||
/// file or a listed script outside it. Another platform's absolute path
|
||||
@@ -975,11 +969,11 @@ fn unreadable_absolute(s: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Is `s` written as a path that the request may not read: an absolute path or a `file:` URL
|
||||
/// ([`unreadable_absolute`]), `~` or a path under it, or a path that starts with `.` or `..` and a
|
||||
/// separator? It is checked as the file access check reads it, relative to the working folder. Other
|
||||
/// text with a separator (`a/b`) stays.
|
||||
/// ([`unreadable_absolute`]), as written or with surrounding whitespace trimmed, `~` or a path under
|
||||
/// it, or a path that starts with `.` or `..` and a separator? It is checked as the file access check
|
||||
/// reads it, relative to the working folder. Other text with a separator (`a/b`) stays.
|
||||
fn unreadable_path_like(s: &str) -> bool {
|
||||
if unreadable_absolute(s) {
|
||||
if unreadable_absolute(s) || unreadable_absolute(s.trim()) {
|
||||
return true;
|
||||
}
|
||||
let t = s.trim_start();
|
||||
@@ -1048,24 +1042,63 @@ fn percent_decoded(s: &str) -> String {
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The params of each step of each action of action set `set` (`ui.action_sets`).
|
||||
fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<String, Value>> {
|
||||
/// The command and params of each step of each action of action set `set` (`ui.action_sets`).
|
||||
fn steps_mut(set: &mut Value) -> impl Iterator<Item = (String, &mut serde_json::Map<String, Value>)> {
|
||||
let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten();
|
||||
let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten();
|
||||
steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut))
|
||||
steps.filter_map(|step| {
|
||||
let command = step.get(0).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
step.get_mut(1).and_then(Value::as_object_mut).map(|params| (command, params))
|
||||
})
|
||||
}
|
||||
|
||||
/// Takes out of the params of a recorded step running `command` the paths the request may not read:
|
||||
/// the folder preferences' values [`leave_out_unreadable_pref_steps`] takes out, and what
|
||||
/// [`leave_out_unreadable_params`] takes out, each in one pass over the params.
|
||||
fn leave_out_unreadable_step(command: &str, params: &mut serde_json::Map<String, Value>) {
|
||||
leave_out_unreadable_pref_steps(command, params);
|
||||
leave_out_unreadable_params(params);
|
||||
}
|
||||
|
||||
/// Takes out of the params of a step running `command` the folder preferences' values the request
|
||||
/// may not read ([`shows_folder`]): in a `prefs.set` step, given as `{key, value}` or in `values`; in
|
||||
/// a `command.batch` step, the same in each of its commands, at any depth.
|
||||
fn leave_out_unreadable_pref_steps(command: &str, params: &mut serde_json::Map<String, Value>) {
|
||||
match command {
|
||||
"prefs.set" => {
|
||||
if let Some(Value::Object(values)) = params.get_mut("values") {
|
||||
leave_out_unreadable_folders(values);
|
||||
}
|
||||
if let (Some(key), Some(value)) = (params.get("key").and_then(Value::as_str), params.get("value").and_then(Value::as_str))
|
||||
&& !shows_folder(key, value)
|
||||
{
|
||||
params.remove("value");
|
||||
}
|
||||
}
|
||||
"command.batch" => {
|
||||
for step in params.get_mut("commands").and_then(Value::as_array_mut).into_iter().flatten().filter_map(Value::as_object_mut) {
|
||||
let command = step.get("command").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
if let Some(Value::Object(params)) = step.get_mut("params") {
|
||||
leave_out_unreadable_pref_steps(&command, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the
|
||||
/// paths the request may not read: the value of a path param
|
||||
/// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder`
|
||||
/// and the like), each path of a list under such a param, and under any other param, as a key too, a
|
||||
/// string written as a path ([`unreadable_path_like`]).
|
||||
/// string written as a path ([`unreadable_path_like`]). An empty or blank value names no path and stays.
|
||||
fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) {
|
||||
use vectorcraft_engine::file_access::is_path_key;
|
||||
let shown = |p: &str| p.trim().is_empty() || readable_value(p);
|
||||
params.retain(|key, v| {
|
||||
!unreadable_path_like(key)
|
||||
&& match v.as_str() {
|
||||
Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)),
|
||||
Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !shown(p)),
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
@@ -1073,7 +1106,7 @@ fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) {
|
||||
if let Value::Array(paths) = v
|
||||
&& is_path_key(key)
|
||||
{
|
||||
paths.retain(|p| p.as_str().is_none_or(readable_value));
|
||||
paths.retain(|p| p.as_str().is_none_or(shown));
|
||||
}
|
||||
leave_out_unreadable_values(v);
|
||||
}
|
||||
@@ -1108,8 +1141,8 @@ fn leave_out_unreadable_paths(app: &VectorcraftApp, ui: &mut serde_json::Map<Str
|
||||
}
|
||||
}
|
||||
}
|
||||
for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) {
|
||||
leave_out_unreadable_params(step);
|
||||
for (command, params) in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) {
|
||||
leave_out_unreadable_step(&command, params);
|
||||
}
|
||||
if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") {
|
||||
leave_out_unreadable_folders(prefs);
|
||||
@@ -1130,20 +1163,24 @@ fn leave_out_unreadable_paths(app: &VectorcraftApp, ui: &mut serde_json::Map<Str
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read, as written
|
||||
/// or trimmed as the app uses it. An empty value (no folder) and a scratch disk choice
|
||||
/// ([`SCRATCH_CHOICES`]) stay.
|
||||
/// Takes out of `prefs` the folder preferences
|
||||
/// ([`vectorcraft_engine::cmd::prefscmds::FOLDER_PREFS`]) the request may not read ([`shows_folder`]).
|
||||
fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) {
|
||||
for key in PREF_FOLDERS {
|
||||
let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue };
|
||||
let path = written.trim();
|
||||
if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || (readable(written) && readable(path)) {
|
||||
continue;
|
||||
for key in vectorcraft_engine::cmd::prefscmds::FOLDER_PREFS {
|
||||
if prefs.get(key).and_then(Value::as_str).is_some_and(|written| !shows_folder(key, written)) {
|
||||
prefs.remove(key);
|
||||
}
|
||||
prefs.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// May the request see `written` as the value of preference `key`? Yes unless it names a folder
|
||||
/// ([`vectorcraft_engine::cmd::prefscmds::names_a_folder`]: an empty value and a scratch disk choice
|
||||
/// do not) that the request may not read as written, or trimmed as the app uses it, also as a `file:`
|
||||
/// URL ([`readable_value`]).
|
||||
fn shows_folder(key: &str, written: &str) -> bool {
|
||||
!vectorcraft_engine::cmd::prefscmds::names_a_folder(key, written) || (readable(written) && readable_value(written.trim()))
|
||||
}
|
||||
|
||||
fn key_from(name: &str) -> Option<egui::Key> {
|
||||
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
|
||||
"enter" | "return" => Some(egui::Key::Enter),
|
||||
|
||||
@@ -293,6 +293,42 @@ fn an_absolute_path_outside_the_read_root_is_unreadable() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Under a read root, an absolute path or a `file:` URL outside it is written as a path also with
|
||||
/// whitespace around it, and a recorded step's text param loses it; a path inside the root stays.
|
||||
#[test]
|
||||
fn a_spaced_absolute_path_outside_the_read_root_is_left_out() {
|
||||
let base = crate::tests_folder::Folder::new("unreadable-spaced");
|
||||
std::fs::create_dir_all(base.join("in")).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
|
||||
let inside = format!(" {} ", base.join("in").join("a.svg").to_string_lossy());
|
||||
let mut params = json!({"target": " /outside/root/x", "name": "\tfile:///outside/root/x ", "keep": inside, "list": [" /outside/root/y"]});
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
assert!(super::unreadable_path_like(" /outside/root/x"));
|
||||
assert!(super::unreadable_path_like("\tfile:///outside/root/x "));
|
||||
assert!(!super::unreadable_path_like(&inside), "{inside}");
|
||||
super::leave_out_unreadable_params(params.as_object_mut().unwrap());
|
||||
});
|
||||
assert_eq!(params, json!({"keep": inside, "list": []}));
|
||||
}
|
||||
|
||||
/// Under a read root, an empty or blank value names no path, so a recorded step keeps it under a path
|
||||
/// param and in a path param's list, a folder preference reset in a `prefs.set` step's `values`
|
||||
/// included; a path outside the root is still left out.
|
||||
#[test]
|
||||
fn an_empty_path_param_stays_under_a_read_root() {
|
||||
let base = crate::tests_folder::Folder::new("unreadable-empty");
|
||||
std::fs::create_dir_all(base.join("in")).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
|
||||
let mut params = json!({
|
||||
"values": {"fontsFolder": "", "templatesFolder": " ", "recoveryFolder": "/outside/root/x"},
|
||||
"path": "", "destinationFolder": " ", "files": ["", "/outside/root/y"],
|
||||
});
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
super::leave_out_unreadable_step("prefs.set", params.as_object_mut().unwrap());
|
||||
});
|
||||
assert_eq!(params, json!({"values": {"fontsFolder": "", "templatesFolder": " "}, "path": "", "destinationFolder": " ", "files": [""]}));
|
||||
}
|
||||
|
||||
/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps
|
||||
/// it.
|
||||
#[test]
|
||||
|
||||
@@ -783,6 +783,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
return window(app, ctx);
|
||||
}
|
||||
let mut ok = false;
|
||||
let mut ok_clicked = false;
|
||||
let mut ok_enabled = true;
|
||||
let mut cancel = false;
|
||||
let mut reset = false;
|
||||
@@ -843,7 +844,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
|
||||
&& widgets::primary_button_enabled(ui, label, ok_enabled).clicked()
|
||||
{
|
||||
ok = true;
|
||||
ok_clicked = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// While Alt (Option) is held, Cancel reads Reset on a dialog that has one.
|
||||
@@ -878,6 +879,20 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
});
|
||||
let rejected = ctx.data_mut(|d| d.remove_temp::<bool>(widgets::dialog_reject_commit_flag())).unwrap_or(false);
|
||||
// A field that refused its text when the press on OK took its focus keeps the dialog open, as
|
||||
// Enter does. Only a pointer click has such a press; the keyboard and screen readers press OK
|
||||
// in one frame.
|
||||
let press = egui::Id::unique("dialog-press-pass");
|
||||
let (pressed, released) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.any_released()));
|
||||
if pressed {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|m| m.insert_temp(press, pass));
|
||||
}
|
||||
let (pressed_at, refused_at) = ctx.data(|m| (m.get_temp::<u64>(press), m.get_temp::<u64>(widgets::dialog_reject_pass())));
|
||||
let refused_during_click = released && matches!((refused_at, pressed_at), (Some(r), Some(p)) if r >= p);
|
||||
if ok_clicked && !rejected && !refused_during_click {
|
||||
ok = true;
|
||||
}
|
||||
if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && crate::shortcuts::pressed_anew(ctx, egui::Key::Enter) {
|
||||
ok = true;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,20 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
"type.pathOptions" => &["spacing"],
|
||||
"object.pathfinder.trap" => &["thickness"],
|
||||
crate::addons::cutting::BRIDGES => &["width"],
|
||||
"object.textWrap.options" => &["offset"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The range (points) of a distance parameter of the commands this dialog edits, where the
|
||||
/// command has one: the field refuses a distance typed outside it.
|
||||
pub(super) fn length_range(command: &str, key: &str) -> Option<std::ops::RangeInclusive<f64>> {
|
||||
match (command, key) {
|
||||
("object.textWrap.options", "offset") => Some(vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The order the fields of the commands this dialog edits come in, where the order of their
|
||||
/// names isn't it (the rest follow, by name).
|
||||
fn order(command: &str) -> &'static [&'static str] {
|
||||
@@ -505,4 +515,168 @@ mod tests {
|
||||
assert_eq!(crate::panels::pattern_options::repeat_fields(&app).unwrap()["instances"], 4);
|
||||
assert!(!app.session.in_interaction());
|
||||
}
|
||||
|
||||
/// Text Wrap Options › Offset is a distance, and its range is the one `object.textWrap.options`
|
||||
/// keeps an Offset to.
|
||||
#[test]
|
||||
fn text_wrap_offset_is_a_distance_within_the_commands_range() {
|
||||
assert!(lengths("object.textWrap.options").contains(&"offset"));
|
||||
assert!(!lengths("object.textWrap.options").contains(&"invert"));
|
||||
let range = length_range("object.textWrap.options", "offset").expect("a range");
|
||||
assert_eq!(range, vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE);
|
||||
assert_eq!(length_range("object.textWrap.options", "invert"), None);
|
||||
assert_eq!(length_range("text.areaOptions", "inset"), None);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
for (sent, end) in [(5000.0, *range.end()), (-5000.0, *range.start())] {
|
||||
assert_eq!(s.execute("object.textWrap.options", &json!({ "offset": sent })).unwrap()["offset"], json!(end));
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the text of the dialog's Offset field with `text`, leaving the field focused.
|
||||
fn type_offset(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str) {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let all = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
|
||||
h.get_by_role_and_label(Role::TextInput, "Offset:").focus();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(all, egui::Key::A);
|
||||
h.get_by_role_and_label(Role::TextInput, "Offset:").type_text(text);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Replaces the text of the dialog's Offset field with `text` and presses Enter.
|
||||
fn enter_offset(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str) {
|
||||
type_offset(h, text);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Text Wrap Options › Offset reads a bare number in the document's units and a typed unit as
|
||||
/// typed, and OK sends points. A distance typed outside the command's range, or text that
|
||||
/// doesn't read, is refused: the dialog stays open and the Offset isn't changed.
|
||||
#[test]
|
||||
fn text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("document.setUnits", json!({"units": "millimeters"})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("object.textWrap.make", json!({})).unwrap();
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
crate::dialogs::show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
let offset = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>| {
|
||||
h.state_mut().session.execute("object.textWrap.options", &json!({})).unwrap()["offset"].as_f64().unwrap()
|
||||
};
|
||||
// The ends as the field shows them in millimeters (352.7778 mm is 1000.00006 pt) read
|
||||
// back as the ends.
|
||||
let ends = [("-1000 pt", -1000.0), ("352.7778", 1000.0), ("-352.7778", -1000.0), ("1000 pt", 1000.0)];
|
||||
for (text, want) in [("10", 10.0 * 72.0 / 25.4), ("-12 pt", -12.0)].into_iter().chain(ends) {
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
enter_offset(&mut h, text);
|
||||
assert!(h.state().ui.dialog.is_none(), "{text}: OK applied it");
|
||||
assert!((offset(&mut h) - want).abs() < 1e-9, "{text}: {} pt", offset(&mut h));
|
||||
}
|
||||
for text in ["1000.5 pt", "-2000", "abc"] {
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
enter_offset(&mut h, text);
|
||||
let d = h.state().ui.dialog.as_ref().unwrap_or_else(|| panic!("{text}: the dialog stays open"));
|
||||
assert_eq!(d.fields.get("offset"), Some(&json!(1000.0)), "{text}: the field keeps its value");
|
||||
crate::dialogs::cancel(h.state_mut());
|
||||
assert_eq!(offset(&mut h), 1000.0, "{text}: the Offset is unchanged");
|
||||
}
|
||||
// A click on OK with a value outside the range still in the field is refused too.
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
type_offset(&mut h, "2000 pt");
|
||||
click_ok(&mut h);
|
||||
assert!(h.state().ui.dialog.is_some(), "OK leaves the dialog open");
|
||||
assert_eq!(offset(&mut h), 1000.0, "the Offset is unchanged");
|
||||
// After it, a value inside the range committed with Tab and OK pressed from the keyboard
|
||||
// apply.
|
||||
type_offset(&mut h, "-7 pt");
|
||||
h.key_press(egui::Key::Tab);
|
||||
h.run_steps(2);
|
||||
{
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label("OK").focus();
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Space);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.dialog.is_none(), "OK from the keyboard applied it");
|
||||
assert_eq!(offset(&mut h), -7.0);
|
||||
// One inside it applies.
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
type_offset(&mut h, "-5 pt");
|
||||
click_ok(&mut h);
|
||||
assert!(h.state().ui.dialog.is_none(), "OK applied it");
|
||||
assert_eq!(offset(&mut h), -5.0);
|
||||
}
|
||||
|
||||
/// In a document in picas, Text Wrap Options › Offset reads a bare number as the other length
|
||||
/// fields in picas do: in points with Preferences › Units › Numbers Without Units Are Points on
|
||||
/// (the default), in picas with it off. A typed unit is read as typed either way.
|
||||
#[test]
|
||||
fn text_wrap_offset_in_picas_follows_numbers_without_units_are_points() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("document.setUnits", json!({"units": "picas"})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("object.textWrap.make", json!({})).unwrap();
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
crate::dialogs::show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
let apply = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str| {
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
enter_offset(h, text);
|
||||
assert!(h.state().ui.dialog.is_none(), "{text}: OK applied it");
|
||||
h.state_mut().session.execute("object.textWrap.options", &json!({})).unwrap()["offset"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(apply(&mut h, "10"), 10.0, "on by default: a bare number is in points");
|
||||
assert_eq!(apply(&mut h, "2p6"), 30.0, "picas and points as typed");
|
||||
assert_eq!(apply(&mut h, "-1in"), -72.0, "a typed unit wins");
|
||||
crate::widgets::set_bare_numbers_are_points(&h.ctx, false);
|
||||
assert_eq!(apply(&mut h, "10"), 120.0, "off: a bare number is in picas");
|
||||
assert_eq!(apply(&mut h, "-12 pt"), -12.0, "a typed unit wins");
|
||||
}
|
||||
|
||||
/// Clicks the dialog's OK button with the pointer, the press and the release in frames of
|
||||
/// their own.
|
||||
fn click_ok(h: &mut egui_kittest::Harness<'_, VectorcraftApp>) {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let ok = h.get_by_label("OK").rect().center();
|
||||
let (button, modifiers) = (egui::PointerButton::Primary, egui::Modifiers::default());
|
||||
h.event(egui::Event::PointerMoved(ok));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: ok, button, pressed: true, modifiers });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: ok, button, pressed: false, modifiers });
|
||||
h.run_steps(3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +453,14 @@ pub(super) fn param_fields(
|
||||
if is_length(&k) {
|
||||
// An effect's documented range keeps the field to the distance it applies.
|
||||
let range = vectorcraft_effects::length_range(&d.str("__effect"), &k);
|
||||
if spinners {
|
||||
// A command's own range refuses a distance typed outside it.
|
||||
if let Some(bounds) = super::command::length_range(&d.str("__command"), &k) {
|
||||
let value = d.fields.contains_key(&k).then(|| d.f64(&k, 0.0));
|
||||
if let Some(x) = crate::widgets::num_field_refusing(ui, ("dlg-len", k.as_str()), value, unit, 140.0, bounds) {
|
||||
d.fields.insert(k.clone(), json!(x));
|
||||
changed = true;
|
||||
}
|
||||
} else if spinners {
|
||||
let bounds = range.unwrap_or(if k == "blur" { 0.0..=f64::INFINITY } else { crate::widgets::ANY });
|
||||
let value = d.fields.contains_key(&k).then(|| d.f64(&k, 0.0));
|
||||
if let Some(x) = crate::widgets::spin_length_within(ui, ("dlg-len", k.as_str()), value, unit, 140.0, bounds) {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! `tool.setOption {tool: "livePaintBucket", values}`.
|
||||
//!
|
||||
//! Fields: `paintFills` and `paintStrokes` (the faces and the edges of Live Paint groups),
|
||||
//! `highlight` (whether the face or edge under the pointer is outlined), `color` (the highlight
|
||||
//! `cursorSwatchPreview` (whether the active paint and the color swatches beside it show above the
|
||||
//! pointer), `highlight` (whether the face or edge under the pointer is outlined), `color` (the highlight
|
||||
//! color: `#rrggbb`, or a layer color's name such as "Red") and `width` (the width of a face's
|
||||
//! highlight in screen pixels; an edge's highlight is half as wide).
|
||||
|
||||
@@ -25,7 +26,7 @@ pub const KIND: &str = "livePaintBucketOptions";
|
||||
const TOOL: &str = "livePaintBucket";
|
||||
|
||||
/// The options the dialog edits.
|
||||
const KEYS: [&str; 5] = ["paintFills", "paintStrokes", "highlight", "color", "width"];
|
||||
const KEYS: [&str; 6] = ["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight", "color", "width"];
|
||||
|
||||
/// Width of the label column.
|
||||
const LABEL_W: f32 = 60.0;
|
||||
@@ -48,6 +49,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::check(ui, d, "paintFills", tl!("Paint Fills"));
|
||||
form::check(ui, d, "paintStrokes", tl!("Paint Strokes"));
|
||||
form::check(ui, d, "cursorSwatchPreview", tl!("Cursor Swatch Preview"));
|
||||
ui.add_space(6.0);
|
||||
form::check(ui, d, "highlight", tl!("Highlight"));
|
||||
let color = resolve(&d.str("color")).unwrap_or(Color::BLACK);
|
||||
@@ -120,17 +122,18 @@ mod tests {
|
||||
|
||||
crate::toolbar::open_options(&mut app, TOOL).unwrap();
|
||||
let text = painted(&mut app);
|
||||
for label in ["Live Paint Bucket Options", "Paint Fills", "Paint Strokes", "Highlight", "Color:", "Width:"] {
|
||||
for label in ["Live Paint Bucket Options", "Paint Fills", "Paint Strokes", "Cursor Swatch Preview", "Highlight", "Color:", "Width:"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!(
|
||||
(d.bool("paintFills"), d.bool("paintStrokes"), d.bool("highlight"), d.str("color"), d.f64("width", 0.0)),
|
||||
(true, false, true, "#ff2a2a".to_string(), 4.0)
|
||||
(d.bool("paintFills"), d.bool("paintStrokes"), d.bool("cursorSwatchPreview"), d.bool("highlight"), d.str("color"), d.f64("width", 0.0)),
|
||||
(true, false, true, true, "#ff2a2a".to_string(), 4.0)
|
||||
);
|
||||
d.fields.insert("paintFills".into(), json!(false));
|
||||
d.fields.insert("paintStrokes".into(), json!(true));
|
||||
d.fields.insert("cursorSwatchPreview".into(), json!(false));
|
||||
d.fields.insert("color".into(), json!("Green"));
|
||||
d.fields.insert("width".into(), json!(8));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
@@ -141,10 +144,13 @@ mod tests {
|
||||
// They last while another tool is chosen, and the dialog opens on them again.
|
||||
app.select_tool("selection");
|
||||
let o = app.session.tool_options_of(TOOL);
|
||||
assert_eq!((o["paintFills"].as_bool(), o["paintStrokes"].as_bool(), o["color"].as_str()), (Some(false), Some(true), Some("#4fff4f")));
|
||||
assert_eq!(
|
||||
(o["paintFills"].as_bool(), o["paintStrokes"].as_bool(), o["cursorSwatchPreview"].as_bool(), o["color"].as_str()),
|
||||
(Some(false), Some(true), Some(false), Some("#4fff4f"))
|
||||
);
|
||||
app.run("tool.options", json!({"tool": TOOL})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!((d.bool("paintStrokes"), d.str("color")), (true, "#4fff4f".to_string()));
|
||||
assert_eq!((d.bool("paintStrokes"), d.bool("cursorSwatchPreview"), d.str("color")), (true, false, "#4fff4f".to_string()));
|
||||
// Highlight off: the tool outlines nothing under the pointer.
|
||||
d.fields.insert("highlight".into(), json!(false));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
|
||||
@@ -263,6 +263,40 @@ mod tests {
|
||||
assert_eq!((d.str("size"), d.f64("custom", 0.0)), ("large".to_string(), GapSize::LARGE));
|
||||
}
|
||||
|
||||
/// OK on the options the group has, with Preview on or off, closes the dialog and records no
|
||||
/// undo step: the redo step stays and a saved document stays saved.
|
||||
#[test]
|
||||
fn ok_on_unchanged_options_records_no_undo_step() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let g = app.run("livePaint.make", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap();
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [g]})).unwrap();
|
||||
app.session.doc_mut().unwrap().mark_saved();
|
||||
let state = |app: &VectorcraftApp| {
|
||||
let st = app.session.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
};
|
||||
let before = state(&app);
|
||||
for preview in [false, true] {
|
||||
app.run("ui.livePaintGapOptions", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(preview));
|
||||
frame(&mut app);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "preview={preview}: OK closes the dialog");
|
||||
assert_eq!(state(&app), before, "preview={preview}: no undo step");
|
||||
assert_eq!(options(&app, g), GapOptions::default());
|
||||
}
|
||||
// A changed option is one undo step.
|
||||
app.run("ui.livePaintGapOptions", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("detect".into(), json!(true));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(state(&app).0, before.0 + 1);
|
||||
}
|
||||
|
||||
/// A Gap Preview Color with an alpha part is refused and leaves the group's options as they
|
||||
/// were; `#rrggbb` still sets them.
|
||||
#[test]
|
||||
|
||||
@@ -1783,7 +1783,7 @@ mod tests {
|
||||
/// The Color Theme Picker's picks (the `themePicks` field) give the rows that aren't excluded
|
||||
/// the palette of the art picked, in row order, repeating it when the rows outnumber its
|
||||
/// colors, and the art previews them. Picks that touch no art leave the rows as they are, and
|
||||
/// OK keeps the recolor as one undo step.
|
||||
/// OK with rows that give the art its own colors back records no undo step.
|
||||
#[test]
|
||||
fn theme_picks_give_the_rows_the_palette_of_the_art_picked() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
@@ -1822,7 +1822,7 @@ mod tests {
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "nothing is committed before OK");
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!((fill_hex(&app, red), fill_hex(&app, blue)), ("#ff0000".into(), "#0000ff".into()), "the excluded row kept red");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "the art is unchanged: no undo step");
|
||||
}
|
||||
|
||||
const RED: &str = "rgb 255 0 0";
|
||||
|
||||
@@ -5889,6 +5889,7 @@ theme Appearance Външен вид
|
||||
@msg no such color няма такъв цвят
|
||||
@msg {_1} cannot be used on Windows {_1} не може да се използва в Windows
|
||||
@msg more than one action is named “{_1}” повече от едно действие се казва „{_1}“
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set действието „{_1}“ задава предпочитанието за папка {_2}; изтрийте тази стъпка, за да запазите набора
|
||||
@msg no symbol named `{n}` няма символ с име `{n}`
|
||||
Smudge Stick… Размазваща пръчка…
|
||||
Smudge Stick Размазваща пръчка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5892,6 +5892,7 @@ mark-style Japanese Japanski
|
||||
@msg no such color nema takve boje
|
||||
@msg {_1} cannot be used on Windows {_1} se ne može koristiti na Windowsu
|
||||
@msg more than one action is named “{_1}” više od jedne akcije ima naziv „{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set akcija „{_1}” postavlja postavku foldera {_2}; izbrišite taj korak da biste sačuvali skup
|
||||
@msg no symbol named `{n}` nema simbola pod nazivom `{n}`
|
||||
Smudge Stick… Štapić za razmazivanje…
|
||||
Smudge Stick Štapić za razmazivanje
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5771,6 +5771,7 @@ digits Malayalam Malaiàlam
|
||||
@msg no such color no existeix aquest color
|
||||
@msg {_1} cannot be used on Windows {_1} no es pot fer servir al Windows
|
||||
@msg more than one action is named “{_1}” més d’una acció es diu “{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set l’acció “{_1}” defineix la preferència de carpeta {_2}; suprimiu aquest pas per desar el conjunt
|
||||
@msg name the file poseu un nom al fitxer
|
||||
@msg New Window isn't available yet: a document opens in one window Finestra nova encara no està disponible: un document s’obre en una sola finestra
|
||||
@msg no active document no hi ha cap document actiu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6203,6 +6203,7 @@ mark-style Japanese Japonské
|
||||
@msg no such color taková barva neexistuje
|
||||
@msg {_1} cannot be used on Windows Zkratku {_1} nelze ve Windows použít
|
||||
@msg more than one action is named “{_1}” název „{_1}“ má více než jedna akce
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set akce „{_1}“ nastavuje předvolbu složky {_2}; chcete-li sadu uložit, tento krok smažte
|
||||
@msg no symbol named `{n}` žádný symbol s názvem `{n}`
|
||||
Smudge Stick… Rozmazávací tyčinka…
|
||||
Smudge Stick Rozmazávací tyčinka
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5771,6 +5771,7 @@ swatch Bright Violet Kraftig violet
|
||||
@msg no such color ingen sådan farve
|
||||
@msg {_1} cannot be used on Windows {_1} kan ikke bruges i Windows
|
||||
@msg more than one action is named “{_1}” mere end én handling hedder »{_1}«
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set handlingen »{_1}« angiver mappeindstillingen {_2}; slet det trin for at gemme sættet
|
||||
@msg no symbol named `{n}` intet symbol med navnet `{n}`
|
||||
Remove Color Profile… Fjern farveprofil…
|
||||
Color wheel Farvehjul
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
File diff suppressed because it is too large
Load Diff
@@ -6155,6 +6155,7 @@ effect Points Σημεία
|
||||
@msg no such color δεν υπάρχει τέτοιο χρώμα
|
||||
@msg {_1} cannot be used on Windows Το {_1} δεν μπορεί να χρησιμοποιηθεί στα Windows
|
||||
@msg more than one action is named “{_1}” περισσότερες από μία ενέργειες ονομάζονται «{_1}»
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set η ενέργεια «{_1}» ορίζει την προτίμηση φακέλου {_2}· διαγράψτε αυτό το βήμα για να αποθηκεύσετε το σύνολο
|
||||
@msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}`
|
||||
Smudge Stick… Μουτζούρα…
|
||||
Smudge Stick Μουτζούρα
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6146,6 +6146,7 @@ mark-style Japanese Japonesas
|
||||
@msg no such color no existe ese color
|
||||
@msg {_1} cannot be used on Windows {_1} no se puede usar en Windows
|
||||
@msg more than one action is named “{_1}” más de una acción se llama “{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set la acción “{_1}” establece la preferencia de carpeta {_2}; elimine ese paso para guardar el conjunto
|
||||
@msg no symbol named `{n}` no hay ningún símbolo llamado `{n}`
|
||||
Smudge Stick… Difuminar con barra…
|
||||
Smudge Stick Difuminar con barra
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6166,6 +6166,7 @@ mark-style Japanese Japanilainen
|
||||
@msg no such color väriä ei ole
|
||||
@msg {_1} cannot be used on Windows {_1} ei ole käytettävissä Windowsissa
|
||||
@msg more than one action is named “{_1}” useamman kuin yhden toiminnon nimi on ”{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set toiminto ”{_1}” asettaa kansioasetuksen {_2}; poista tämä vaihe, jotta voit tallentaa sarjan
|
||||
@msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole
|
||||
Smudge Stick… Sumennuskynä…
|
||||
Smudge Stick Sumennuskynä
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6198,6 +6198,7 @@ theme Appearance Apparence
|
||||
@msg no such color couleur inexistante
|
||||
@msg {_1} cannot be used on Windows {_1} ne peut pas être utilisé sous Windows
|
||||
@msg more than one action is named “{_1}” plusieurs scripts s’appellent « {_1} »
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set le script « {_1} » définit la préférence de dossier {_2} ; supprimez cette étape pour enregistrer le jeu
|
||||
@msg no symbol named `{n}` aucun symbole nommé `{n}`
|
||||
Smudge Stick… Estompe…
|
||||
Smudge Stick Estompe
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -7218,6 +7218,7 @@ antiAlias Sharp शार्प
|
||||
@msg more than 10000 linked files: package dependencies in smaller parts 10000 से अधिक लिंक्ड फ़ाइलें: पैकेज निर्भरता छोटे भागों में
|
||||
@msg more than 256 commands 256 से अधिक कमांड
|
||||
@msg more than one action is named “{_1}” एक से अधिक कार्रवाई का नाम "{_1}" रखा गया है।
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set कार्रवाई "{_1}" फ़ोल्डर प्राथमिकता {_2} सेट करती है; सेट सेव करने के लिए वह चरण हटाएँ
|
||||
@msg Move Panel to New Window is not available: floating panels stay in the main window नई विंडो में पैनल को स्थानांतरित करना उपलब्ध नहीं है: अस्थायी पैनल मुख्य विंडो में रहते हैं
|
||||
@msg move requires a destination panel move के लिए गंतव्य पैनल चाहिए
|
||||
@msg move requires an existing destination panel move के लिए मौजूदा गंतव्य पैनल चाहिए
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6186,6 +6186,7 @@ imageTrace Outline Körvonal
|
||||
@msg no such color nincs ilyen szín
|
||||
@msg {_1} cannot be used on Windows A(z) {_1} nem használható Windows rendszeren
|
||||
@msg more than one action is named “{_1}” több művelet neve is „{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set a(z) „{_1}” művelet beállítja a(z) {_2} mappabeállítást; a készlet mentéséhez törölje ezt a lépést
|
||||
@msg no symbol named `{n}` nincs `{n}` nevű szimbólum
|
||||
Smudge Stick… Elmosó rúd…
|
||||
Smudge Stick Elmosó rúd
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5771,6 +5771,7 @@ swatch Bright Violet Violet cerah
|
||||
@msg no such color tidak ada warna seperti itu
|
||||
@msg {_1} cannot be used on Windows {_1} tidak dapat digunakan di Windows
|
||||
@msg more than one action is named “{_1}” lebih dari satu tindakan bernama “{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set tindakan “{_1}” mengatur preferensi folder {_2}; hapus langkah itu untuk menyimpan set
|
||||
@msg no symbol named `{n}` tidak ada simbol bernama `{n}`
|
||||
Remove Color Profile… Hapus profil warna…
|
||||
Color wheel Roda warna
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2429 and column 5.
|
File diff suppressed because it is too large
Load Diff
@@ -5868,6 +5868,7 @@ lighting Light ライト
|
||||
@msg no such color 指定されたカラーが見つかりません
|
||||
@msg {_1} cannot be used on Windows {_1} は Windows では使用できません
|
||||
@msg more than one action is named “{_1}” 「{_1}」という名前のアクションが複数あります
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set アクション「{_1}」がフォルダーの環境設定 {_2} を設定しています。セットを保存するにはそのステップを削除してください
|
||||
@msg no symbol named `{n}` `{n}` という名前のシンボルはありません
|
||||
Smudge Stick… スマッジスティック…
|
||||
Smudge Stick スマッジスティック
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5927,6 +5927,7 @@ imageTrace Outline კონტური
|
||||
@msg no such color ასეთი ფერი არ არის
|
||||
@msg {_1} cannot be used on Windows {_1} ვერ გამოიყენება Windows-ში
|
||||
@msg more than one action is named “{_1}” „{_1}“ ერთზე მეტი მოქმედების სახელია
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set მოქმედება „{_1}“ აყენებს საქაღალდის პარამეტრს {_2}; ნაკრების შესანახად წაშალეთ ეს ნაბიჯი
|
||||
@msg no symbol named `{n}` სიმბოლო სახელით `{n}` არ არის
|
||||
@msg no saved selection recalled შენახული მონიშვნა გამოძახებული არ არის
|
||||
@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft-ის ბოლო გაშვება ფანჯრის გამოჩენამდე შეწყდა (გრაფიკული პროცესორი: {name}), ამიტომ VectorCraft გაეშვა: {summary}. ამ გრაფიკული პროცესორის ხელახლა საცდელად შეცვალეთ პარამეტრები › წარმადობა › გრაფიკული პროცესორი ან გაუშვით WGPU_BACKEND ცვლადის მითითებით
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5785,6 +5785,7 @@ lighting Light 광원
|
||||
@msg no such color 해당 색상이 없습니다
|
||||
@msg {_1} cannot be used on Windows {_1}은(는) Windows에서 사용할 수 없습니다
|
||||
@msg more than one action is named “{_1}” “{_1}”(이)라는 이름의 액션이 둘 이상 있습니다
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set 액션 “{_1}”이(가) 폴더 환경 설정 {_2}을(를) 설정합니다. 세트를 저장하려면 해당 단계를 삭제하세요
|
||||
@msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다
|
||||
Smudge Stick… 문지르기 막대…
|
||||
Smudge Stick 문지르기 막대
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -8165,6 +8165,7 @@ swatch Bright Violet Felviolet
|
||||
@msg no such color kleur bestaat niet
|
||||
@msg {_1} cannot be used on Windows {_1} kan niet worden gebruikt in Windows
|
||||
@msg more than one action is named “{_1}” meer dan één actie heet „{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set de actie „{_1}” stelt de mapvoorkeur {_2} in; verwijder die stap om de set op te slaan
|
||||
@msg no symbol named `{n}` geen symbool met de naam `{n}`
|
||||
Remove Color Profile… Kleurprofiel verwijderen…
|
||||
Color wheel Kleurenwiel
|
||||
|
||||
|
Can't render this file because it is too large.
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5809,6 +5809,7 @@ lighting Light Свет
|
||||
@msg no such color такого цвета нет
|
||||
@msg {_1} cannot be used on Windows Сочетание {_1} нельзя использовать в Windows
|
||||
@msg more than one action is named “{_1}” несколько действий называются «{_1}»
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set действие «{_1}» задаёт настройку папки {_2}; удалите этот шаг, чтобы сохранить набор
|
||||
@msg no symbol named `{n}` нет символа с именем `{n}`
|
||||
Smudge Stick… Растушёвка…
|
||||
Smudge Stick Растушёвка
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6156,6 +6156,7 @@ theme Appearance Utseende
|
||||
@msg no such color färgen finns inte
|
||||
@msg {_1} cannot be used on Windows {_1} kan inte användas i Windows
|
||||
@msg more than one action is named “{_1}” fler än en åtgärd heter ”{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set åtgärden ”{_1}” anger mappinställningen {_2}; ta bort det steget för att spara uppsättningen
|
||||
@msg no symbol named `{n}` ingen symbol med namnet `{n}`
|
||||
Angular Vinkelmått
|
||||
Away from the Line Bort från linjen
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1486,7 +1486,8 @@ fn string_literals(e: &str, keep: impl Fn(&str, &str) -> bool) -> Vec<String> {
|
||||
|
||||
/// For translators, `cargo test -p vectorcraft-ui-egui dump_source_strings` writes every English
|
||||
/// interface string to `VECTORCRAFT_I18N_DUMP=<file>`, and every status and error message as a
|
||||
/// catalog template to `VECTORCRAFT_I18N_DUMP_MESSAGES=<file>`, one per line.
|
||||
/// catalog template that [`untranslated_report`] checks to `VECTORCRAFT_I18N_DUMP_MESSAGES=<file>`,
|
||||
/// one per line.
|
||||
#[test]
|
||||
fn dump_source_strings() {
|
||||
if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP") {
|
||||
@@ -1498,10 +1499,27 @@ fn dump_source_strings() {
|
||||
std::fs::write(path, text).expect("write dump");
|
||||
}
|
||||
if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_MESSAGES") {
|
||||
std::fs::write(path, message_literals().iter().map(|m| format!("{m}\n")).collect::<String>()).expect("write dump");
|
||||
std::fs::write(path, message_dump()).expect("write dump");
|
||||
}
|
||||
}
|
||||
|
||||
/// The text [`dump_source_strings`] writes to `VECTORCRAFT_I18N_DUMP_MESSAGES`.
|
||||
fn message_dump() -> String {
|
||||
reported_messages().iter().map(|m| format!("{m}\n")).collect()
|
||||
}
|
||||
|
||||
/// The translators' message dump lists every message the untranslated report checks, the
|
||||
/// phrases a parameter schema's error builds included.
|
||||
#[test]
|
||||
fn message_dump_lists_the_reported_messages() {
|
||||
let dump = message_dump();
|
||||
let lines: std::collections::BTreeSet<&str> = dump.lines().collect();
|
||||
for phrase in SCHEMA_PHRASES {
|
||||
assert!(lines.contains(phrase), "the dump skips {phrase:?}");
|
||||
}
|
||||
assert!(lines.contains("the color book is cut short"));
|
||||
}
|
||||
|
||||
fn cs() -> Lang {
|
||||
Lang::from_code("cs").expect("cs registered")
|
||||
}
|
||||
@@ -1640,7 +1658,7 @@ fn untranslated_report() {
|
||||
undo.extend(liquify.iter().filter_map(|k| vectorcraft_tools::distort::liquify::LiquifyKind::parse(k)).map(|k| format!("{} Tool", k.label())));
|
||||
assert_eq!(scan.formatted.iter().collect::<Vec<_>>(), ["{} Tool"], "step names built with format! (make them with Session::edit_named)");
|
||||
assert!(scan.templates.len() >= 3, "scan found only the templates {:?}", scan.templates);
|
||||
let messages = message_literals();
|
||||
let messages = reported_messages();
|
||||
assert!(messages.len() > 300, "scan found only {} messages", messages.len());
|
||||
let (swatches, groups) = vectorcraft_color::default_swatches(vectorcraft_color::cms::Model::Rgb);
|
||||
// The greys' `K=` values read the same in any language.
|
||||
@@ -1741,6 +1759,36 @@ fn untranslated_report() {
|
||||
println!("{out}");
|
||||
}
|
||||
|
||||
/// The status and error messages [`untranslated_report`] checks: the message literals of
|
||||
/// [`MESSAGE_CRATES`] and the phrases a parameter schema's error builds at run time
|
||||
/// ([`SCHEMA_PHRASES`]), which no literal holds.
|
||||
fn reported_messages() -> std::collections::BTreeSet<String> {
|
||||
let mut messages = message_literals();
|
||||
messages.extend(SCHEMA_PHRASES.iter().map(|p| p.to_string()));
|
||||
messages
|
||||
}
|
||||
|
||||
/// The untranslated report checks the messages of the effects and of the crates whose errors the
|
||||
/// engine and the effects pass on, and the phrases a parameter schema's error builds.
|
||||
#[test]
|
||||
fn untranslated_report_checks_every_message_crate_and_schema_phrase() {
|
||||
let reported = reported_messages();
|
||||
let passed_on = [
|
||||
("effects", "the SVG filter has no primitives"),
|
||||
("color", "not a swatch library (.vcswatches or .gpl)"),
|
||||
("render", "invalid scale {scale}"),
|
||||
("three-d", "3D effects require one to ten lights"),
|
||||
("pathops", "the shapes are too degenerate to combine"),
|
||||
];
|
||||
for (krate, m) in passed_on {
|
||||
assert!(message_literals_in(&[krate]).contains(m), "{krate}: {m}");
|
||||
assert!(reported.contains(m), "the report skips {krate}: {m}");
|
||||
}
|
||||
for phrase in SCHEMA_PHRASES {
|
||||
assert!(reported.contains(*phrase), "the report skips the schema phrase {phrase:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The names [`MENU_KEEP_AS_IS`] lists are menu labels, and no catalog that keeps them in English
|
||||
/// translates them.
|
||||
#[test]
|
||||
@@ -2723,6 +2771,84 @@ fn f(i: &mut Interp, interp: &mut Interp) -> JsResult<Value> {
|
||||
assert_eq!(found, ["{what}: the object is not a File or a Folder", "File.seek: the mode must be 0, 1 or 2", "the path has an empty step"]);
|
||||
}
|
||||
|
||||
/// The scan reads the message a `.map_err` closure returns, a literal, a `format!` string or a
|
||||
/// constant, whatever the closure's parameter, from each branch of an `if` and each arm of a
|
||||
/// `match`, and given to a variant of an enum whose name ends in `Fail`, and not a closure that
|
||||
/// passes the error on, a `json!` value's keys, nor the steps of a script result's path
|
||||
/// ([`NOT_MESSAGES`]).
|
||||
#[test]
|
||||
fn message_scan_reads_map_err_closures() {
|
||||
let code = r#"
|
||||
fn f(text: &str, n: usize) -> Result<u32, String> {
|
||||
let doc = parse(text).map_err(|e| format!("the filter isn't valid XML: {e}"))?;
|
||||
let a = at(doc).map_err(|place| format!("[{i}]{place}"))?;
|
||||
let b = at(doc).map_err(|place| format!(".{k}{place}"))?;
|
||||
let c = at(doc).map_err(|e| format!("{name}: {e}"))?;
|
||||
let n = u32::try_from(n).map_err(|_| "too many blocks".to_string())?;
|
||||
let m = u16::try_from(n).map_err(move |(a, b)| LIMIT.into())?;
|
||||
let k = read(doc).map_err(|e| e.to_string())?;
|
||||
let j = read(doc).map_err(|e| bad(C, e))?;
|
||||
let i = read(doc).map_err(|e| if e == "{x}" { CUT_SHORT.into() } else { e })?;
|
||||
let h = read(doc).map_err(|e| match e {
|
||||
Error::Io(_) => "the file can't be read".to_string(),
|
||||
other => fail(json!({"objects": [other.to_string()]})),
|
||||
})?;
|
||||
let g = i32::try_from(n).map_err(|_| CompileFail::Syntax("the pattern is too large".into()))?;
|
||||
let f = read(doc).map_err(|e| {
|
||||
self.published = None;
|
||||
format!("the clipboard can't be read: {e}")
|
||||
})?;
|
||||
Ok(n)
|
||||
}
|
||||
"#;
|
||||
let consts =
|
||||
[("LIMIT", "the name is too long"), ("CUT_SHORT", "the book is cut short")].map(|(k, v)| (k.to_string(), vec![v.to_string()])).into();
|
||||
let want = [
|
||||
"the pattern is too large",
|
||||
"the filter isn't valid XML: {e}",
|
||||
"{name}: {e}",
|
||||
"too many blocks",
|
||||
"the name is too long",
|
||||
"the book is cut short",
|
||||
"the file can't be read",
|
||||
"the clipboard can't be read: {e}",
|
||||
];
|
||||
assert_eq!(source_messages(code, &[], &consts), want);
|
||||
assert!(message_literals_in(&["color"]).contains("the color book is cut short"));
|
||||
assert!(message_literals_in(&["color"]).contains("not a swatch library: {e}"));
|
||||
assert!(message_literals_in(&["js"]).contains("regular expression too large"));
|
||||
assert!(message_literals_in(&["effects"]).contains("the SVG filter isn't valid XML: {e}"));
|
||||
}
|
||||
|
||||
/// The scan reads the messages of an error handler that a `.map_err(name)` names: a closure bound
|
||||
/// to that name, up to its `;`, and a function of that name, each of its statements.
|
||||
#[test]
|
||||
fn message_scan_reads_named_map_err_handlers() {
|
||||
let code = r#"
|
||||
fn encode(img: &Image) -> Result<Vec<u8>, String> {
|
||||
let fail = |e: gif::EncodingError| format!("GIF encoding failed: {e}");
|
||||
let gone = move |_| GONE.to_string();
|
||||
let size = if img.is_empty() { WRONG.into() } else { 0 };
|
||||
let mut enc = Encoder::new(img).map_err(fail)?;
|
||||
enc.write(img).map_err(fail)?;
|
||||
enc.check().map_err(gone)?;
|
||||
enc.close().map_err(unreadable)?;
|
||||
enc.into_inner().map_err(Error::Io)
|
||||
}
|
||||
|
||||
fn unreadable(e: impl std::fmt::Display) -> String {
|
||||
let e = e.to_string();
|
||||
format!("the file can't be read: {e}")
|
||||
}
|
||||
"#;
|
||||
let consts = [("GONE", "the encoder is gone"), ("WRONG", "the image is wrong")].map(|(k, v)| (k.to_string(), vec![v.to_string()])).into();
|
||||
let want = ["GIF encoding failed: {e}", "the encoder is gone", "the file can't be read: {e}"];
|
||||
assert_eq!(source_messages(code, &[], &consts), want);
|
||||
let render = message_literals_in(&["render"]);
|
||||
assert!(render.contains("GIF encoding failed: {e}") && render.contains("JPEG encoding failed: {e}"));
|
||||
assert!(message_literals_in(&["engine"]).contains("Data Recovery can't keep copies in {path}: {e}"));
|
||||
}
|
||||
|
||||
/// The scan stops at a test module whose `cfg` is `test` or `all(test, …)`, and reads on past a
|
||||
/// declaration of a test file and a module that is not only for tests.
|
||||
#[test]
|
||||
@@ -3911,8 +4037,13 @@ const COMPLETE_MESSAGES: &[&str] = &[
|
||||
"nl",
|
||||
];
|
||||
|
||||
/// Crates whose error and status messages reach the status bar.
|
||||
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace", "js"];
|
||||
/// Crates whose error and status messages reach the status bar, the crates whose errors the
|
||||
/// engine and the effects pass on included (effects, swatch libraries, raster sizes and encoders,
|
||||
/// 3D solids, path operations).
|
||||
const MESSAGE_CRATES: &[&str] = &[
|
||||
"ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace", "js", "effects", "color", "render",
|
||||
"three-d", "pathops",
|
||||
];
|
||||
|
||||
/// Where a message literal starts: a status, an error value, a `thiserror` message.
|
||||
const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Other(", "Err(", "ok_or(", "ok_or_else(|| ", "#[error("];
|
||||
@@ -3957,9 +4088,10 @@ const NAME_VARIANTS: &[&str] = &["UnknownCommand", "Ps"];
|
||||
/// `EngineError::Disabled(command id, reason)`.
|
||||
const LATER_MESSAGE_VARIANTS: &[(&str, usize)] = &[("Disabled", 1)];
|
||||
|
||||
/// Messages that are signals, not text for people, and the name of a plug-in export that an
|
||||
/// error message holds (`vc_manifest points outside memory`).
|
||||
const NOT_MESSAGES: &[&str] = &["quit", "cancelled", "vc_manifest"];
|
||||
/// Messages that are signals, not text for people, the name of a plug-in export that an error
|
||||
/// message holds (`vc_manifest points outside memory`), and the steps of the path to a script
|
||||
/// result's part that an error message holds (`vc.run: the result{place} is too large for a script`).
|
||||
const NOT_MESSAGES: &[&str] = &["quit", "cancelled", "vc_manifest", "[{i}]{place}", ".{k}{place}"];
|
||||
|
||||
/// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog
|
||||
/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`).
|
||||
@@ -4077,8 +4209,11 @@ fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap<String, Ve
|
||||
|
||||
/// The message literals in `code` outside its comments, as catalog templates (see
|
||||
/// [`message_literals`]): the literal after each of [`MESSAGE_MARKERS`], the message argument of an
|
||||
/// error enum's variant (the first, as in `PsError::Limit(…)` and `Error::Manifest(…)`, or the one
|
||||
/// error enum's variant, an enum whose name ends in `Error` or `Fail` (the first argument, as in
|
||||
/// `PsError::Limit(…)`, `Error::Manifest(…)` and `CompileFail::Syntax(…)`, or the one
|
||||
/// [`LATER_MESSAGE_VARIANTS`] gives; not [`NAME_VARIANTS`]),
|
||||
/// the messages a `.map_err` closure returns (`.map_err(|e| format!("…: {e}"))`, see
|
||||
/// [`closure_returns`]) or the handler a `.map_err(name)` names returns (see [`handler_returns`]),
|
||||
/// and the message argument of each call in `helpers` (a call and the number of arguments before
|
||||
/// its message), a literal, a `format!` string or the name of one of `consts` (see
|
||||
/// [`str_consts`]), each one with or without a `&` before it. A key's name alone (`` `tint` ``)
|
||||
@@ -4086,20 +4221,34 @@ fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap<String, Ve
|
||||
fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collections::BTreeMap<String, Vec<String>>) -> Vec<String> {
|
||||
let code = &without_comments(code);
|
||||
let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..]));
|
||||
let in_variants = code.match_indices("Error::").filter_map(|(at, m)| {
|
||||
let in_variants = ["Error::", "Fail::"].into_iter().flat_map(|enum_end| code.match_indices(enum_end)).filter_map(|(at, m)| {
|
||||
let rest = &code[at + m.len()..];
|
||||
let (variant, args) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len()));
|
||||
let named = variant.starts_with(|c: char| c.is_ascii_uppercase()) && !NAME_VARIANTS.contains(&variant);
|
||||
let before = LATER_MESSAGE_VARIANTS.iter().find(|(v, _)| *v == variant).map_or(0, |&(_, n)| n);
|
||||
if named { skip_args(args.strip_prefix('(')?, before) } else { None }
|
||||
});
|
||||
let in_map_err = code.match_indices(".map_err(").flat_map(|(at, m)| {
|
||||
let rest = code[at + m.len()..].trim_start();
|
||||
let params = rest.strip_prefix("move").map_or(rest, str::trim_start).strip_prefix('|');
|
||||
params.and_then(|p| p.find('|').map(|end| &p[end + 1..])).map(closure_returns).unwrap_or_default()
|
||||
});
|
||||
let mut handlers = vec![];
|
||||
for (at, m) in code.match_indices(".map_err(") {
|
||||
let rest = &code[at + m.len()..];
|
||||
let name = &rest[..rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len())];
|
||||
if name.starts_with(|c: char| c.is_ascii_lowercase()) && rest[name.len()..].starts_with(')') && !handlers.contains(&name) {
|
||||
handlers.push(name);
|
||||
}
|
||||
}
|
||||
let in_handlers = handlers.into_iter().flat_map(|name| handler_returns(code, name));
|
||||
let in_helpers = helpers.iter().flat_map(|&(call, before)| {
|
||||
code.match_indices(call)
|
||||
.filter(|&(at, _)| call.starts_with('.') || !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.'))
|
||||
.filter_map(move |(at, _)| skip_args(&code[at + call.len()..], before))
|
||||
});
|
||||
let mut out = vec![];
|
||||
for rest in after_markers.chain(in_variants).chain(in_helpers) {
|
||||
for rest in after_markers.chain(in_variants).chain(in_map_err).chain(in_handlers).chain(in_helpers) {
|
||||
let rest = rest.trim_start();
|
||||
let rest = rest.strip_prefix('&').map_or(rest, str::trim_start);
|
||||
let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start);
|
||||
@@ -4150,6 +4299,94 @@ fn skip_args(args: &str, n: usize) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Where each value the error handler `name` of a `.map_err(name)` in `code` can return starts:
|
||||
/// the closure the file binds to that name (`let fail = |e| format!("…: {e}");`, up to its `;`)
|
||||
/// or the body of the function of that name (`fn unreadable(e: …) -> String { … }`), read as
|
||||
/// [`closure_returns`] reads a closure.
|
||||
fn handler_returns<'a>(code: &'a str, name: &str) -> Vec<&'a str> {
|
||||
let (bound, declared) = (format!("let {name} = "), format!("fn {name}("));
|
||||
let mut out = vec![];
|
||||
for (at, m) in code.match_indices(bound.as_str()).chain(code.match_indices(declared.as_str())) {
|
||||
if code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_') {
|
||||
continue;
|
||||
}
|
||||
let rest = &code[at + m.len()..];
|
||||
if m == bound {
|
||||
let rest = rest.strip_prefix("move").map_or(rest, str::trim_start);
|
||||
if let Some(body) = rest.strip_prefix('|').and_then(|p| p.find('|').map(|end| &p[end + 1..])) {
|
||||
out.extend(closure_returns(statement(body)));
|
||||
}
|
||||
} else if let Some(open) = rest.find('{') {
|
||||
out.extend(closure_returns(&rest[open + 1..]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The statement `code` starts with, up to its `;` or the bracket that closes the block it is in.
|
||||
fn statement(code: &str) -> &str {
|
||||
let (mut depth, mut chars) = (0usize, code.char_indices());
|
||||
while let Some((at, c)) = chars.next() {
|
||||
match c {
|
||||
'(' | '[' | '{' => depth += 1,
|
||||
')' | ']' | '}' => match depth.checked_sub(1) {
|
||||
Some(d) => depth = d,
|
||||
None => return &code[..at],
|
||||
},
|
||||
';' if depth == 0 => return &code[..at],
|
||||
'"' => loop {
|
||||
match chars.next() {
|
||||
Some((_, '\\')) => {
|
||||
chars.next();
|
||||
}
|
||||
Some((_, '"')) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// Where each value a closure can return starts, `body` being the text after its parameters: the
|
||||
/// body itself, each block or `if`/`else` branch in it (`{ ACB_CUT_SHORT.into() }`, not the
|
||||
/// `{` of a `json!({…})` value), each `match` arm (`=>`) and each statement after a `;` (the
|
||||
/// last expression of a block or a function body), up to the bracket that closes the call the
|
||||
/// closure is given to or the function.
|
||||
fn closure_returns(body: &str) -> Vec<&str> {
|
||||
let mut out = vec![body];
|
||||
let (mut depth, mut chars) = (0usize, body.char_indices());
|
||||
while let Some((at, c)) = chars.next() {
|
||||
match c {
|
||||
'(' | '[' => depth += 1,
|
||||
'{' => {
|
||||
depth += 1;
|
||||
if !body[..at].trim_end().ends_with('(') {
|
||||
out.push(&body[at + 1..]);
|
||||
}
|
||||
}
|
||||
')' | ']' | '}' => match depth.checked_sub(1) {
|
||||
Some(d) => depth = d,
|
||||
None => break,
|
||||
},
|
||||
';' => out.push(&body[at + 1..]),
|
||||
'=' if body[at + 1..].starts_with('>') => out.push(&body[at + 2..]),
|
||||
'"' => loop {
|
||||
match chars.next() {
|
||||
Some((_, '\\')) => {
|
||||
chars.next();
|
||||
}
|
||||
Some((_, '"')) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Source up to its inline test module (`#[cfg(test)] mod tests {`, or a `cfg(all(test, …))` one);
|
||||
/// declarations of test files (`#[cfg(test)] mod tests_x;`) don't end it.
|
||||
fn without_test_module(text: &str) -> &str {
|
||||
|
||||
@@ -5828,6 +5828,7 @@ theme Appearance Görünüm
|
||||
@msg no such color öyle bir renk yok
|
||||
@msg {_1} cannot be used on Windows {_1} Windows'ta kullanılamaz
|
||||
@msg more than one action is named “{_1}” birden fazla eylemin adı “{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set “{_1}” eylemi {_2} klasör tercihini ayarlıyor; seti kaydetmek için o adımı silin
|
||||
@msg no symbol named `{n}` `{n}` adında bir sembol yok
|
||||
Artboard Options: edit with the Artboard tool Çalışma Yüzeyi Seçenekleri: Çalışma Yüzeyi aracıyla düzenleme
|
||||
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG bu henüz açılamayan bir PostScript dosyasıdır (eski bir formatta kaydedilmiş veya PDF uyumluluğu olmayan bir .ai veya bir EPS dosyası): PDF, PDF uyumlu .ai veya SVG olarak kaydedin
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
File diff suppressed because it is too large
Load Diff
@@ -5805,6 +5805,7 @@ theme Appearance Giao diện
|
||||
@msg no such color không có màu như vậy
|
||||
@msg {_1} cannot be used on Windows không thể dùng {_1} trên Windows
|
||||
@msg more than one action is named “{_1}” có nhiều hành động tên là “{_1}”
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set hành động “{_1}” đặt tùy chọn thư mục {_2}; hãy xóa bước đó để lưu bộ
|
||||
@msg no symbol named `{n}` không có biểu tượng nào tên `{n}`
|
||||
Smudge Stick… Bút chì xóa mờ…
|
||||
Smudge Stick Bút chì xóa mờ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
File diff suppressed because it is too large
Load Diff
@@ -5868,6 +5868,7 @@ lighting Light 光源
|
||||
@msg no such color 沒有這個顏色
|
||||
@msg {_1} cannot be used on Windows {_1} 無法在 Windows 上使用
|
||||
@msg more than one action is named “{_1}” 有多個動作名為「{_1}」
|
||||
@msg the action “{_1}” sets the folder preference {_2}; delete that step to save the set 動作「{_1}」設定了資料夾偏好設定 {_2};請刪除該步驟後再儲存動作組
|
||||
@msg no symbol named `{n}` 沒有名為 `{n}` 的符號
|
||||
Smudge Stick… 塗抹棒…
|
||||
Smudge Stick 塗抹棒
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
File diff suppressed because one or more lines are too long
@@ -3512,6 +3512,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ui.actionSet.save` refuses a set with a step that sets a folder preference to a folder, as
|
||||
/// `{key, value}`, in `values` or in a batch step's commands at any depth, with an error that names
|
||||
/// the action and the preference, and writes no file. An empty folder, a scratch disk choice and
|
||||
/// other preferences save.
|
||||
#[test]
|
||||
fn save_actions_refuses_a_step_that_sets_a_folder_preference() {
|
||||
let mut app = app_with_sets();
|
||||
let files = with_files(&mut app, "/sets/x.vcactions");
|
||||
let step = |command: &str, params: Value| (command.to_string(), params);
|
||||
let nested = json!({"commands": [{"command": "command.batch", "params": {"commands": [
|
||||
{"command": "prefs.set", "params": {"values": {"fontsFolder": "fonts"}}},
|
||||
]}}]});
|
||||
for (steps, key) in [
|
||||
(vec![step("prefs.set", json!({"key": "scratchPrimary", "value": "scratch"}))], "scratchPrimary"),
|
||||
(vec![step("prefs.set", json!({"values": {"uiAccent": "green", "templatesFolder": "/Users/me/Templates"}}))], "templatesFolder"),
|
||||
(
|
||||
vec![
|
||||
step("object.move", json!({"dx": 1, "dy": 0})),
|
||||
step(
|
||||
"command.batch",
|
||||
json!({"commands": [{"command": "prefs.set", "params": {"key": "scratchSecondary", "value": " /Volumes/Fast "}}]}),
|
||||
),
|
||||
],
|
||||
"scratchSecondary",
|
||||
),
|
||||
(vec![step("command.batch", nested)], "fontsFolder"),
|
||||
] {
|
||||
app.ui.action_sets[1] = ActionSet::new("Other", vec![Action::new("Scratch", steps)]);
|
||||
let e = app.run(SAVE_SET, json!({"set": 1, "path": "/sets/x.vcactions"})).unwrap_err();
|
||||
assert_eq!(e, format!("the action “Scratch” sets the folder preference {key}; delete that step to save the set"));
|
||||
assert!(files.borrow().is_empty(), "no file is written for {key}");
|
||||
}
|
||||
let kept = vec![
|
||||
step("prefs.set", json!({"values": {"scratchPrimary": "None", "scratchSecondary": " Startup ", "fontsFolder": "", "uiAccent": "green"}})),
|
||||
step("prefs.set", json!({"key": "pluginsFolder", "value": ""})),
|
||||
step("x.y", json!({"key": "fontsFolder", "value": "fonts"})),
|
||||
];
|
||||
app.ui.action_sets[1] = ActionSet::new("Other", vec![Action::new("Scratch", kept)]);
|
||||
assert_eq!(app.run(SAVE_SET, json!({"set": 1, "path": "/sets/x.vcactions"})).unwrap(), json!({"path": "/sets/x.vcactions"}));
|
||||
assert!(files.borrow().contains_key("/sets/x.vcactions"));
|
||||
}
|
||||
|
||||
/// A file that is not an action set file, or is too large, is refused with a message, and no
|
||||
/// set is added.
|
||||
#[test]
|
||||
|
||||
@@ -169,6 +169,30 @@ fn ui_steps_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A folder preference whose value in a step running `command` with `params` names a folder
|
||||
/// ([`vectorcraft_engine::cmd::prefscmds::names_a_folder`]): in a `prefs.set` step, given as
|
||||
/// `{key, value}` or in `values`, and in a `command.batch` step, in any of its commands at any depth.
|
||||
/// Every such value is found, also one under `values` that `{key, value}` replaces when the step runs,
|
||||
/// because [`write_set`] writes the params as they are.
|
||||
fn folder_pref_in(command: &str, params: &Value) -> Option<String> {
|
||||
use vectorcraft_engine::cmd::prefscmds::names_a_folder;
|
||||
match command {
|
||||
"prefs.set" => {
|
||||
let names = |key: &str, value: &Value| value.as_str().is_some_and(|v| names_a_folder(key, v));
|
||||
let pair = params.get("key").and_then(Value::as_str).filter(|key| params.get("value").is_some_and(|v| names(key, v)));
|
||||
let values = params.get("values").and_then(Value::as_object).into_iter().flatten();
|
||||
pair.map(str::to_string).or_else(|| values.filter(|(key, v)| names(key, v)).map(|(key, _)| key.clone()).next())
|
||||
}
|
||||
"command.batch" => {
|
||||
let mut steps = params.get("commands").and_then(Value::as_array).into_iter().flatten();
|
||||
steps.find_map(|step| {
|
||||
folder_pref_in(step.get("command").and_then(Value::as_str).unwrap_or_default(), step.get("params").unwrap_or(&Value::Null))
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `set` as the text of an action set file.
|
||||
pub fn write_set(set: &ActionSet) -> String {
|
||||
let actions = set
|
||||
@@ -281,7 +305,9 @@ pub(super) fn set_file_stem(name: &str) -> String {
|
||||
/// the set (the web downloads it) → `{path}`. `id` is the panel's own set id ([`ActionSet::id`]),
|
||||
/// which the panel menu passes: the set with this id is saved wherever it is when the file dialog
|
||||
/// answers, and `set` is not read. A set that [`read_set`] would refuse (larger than 8 MiB as a
|
||||
/// file, more than 1,000 actions, an action with more than 10,000 steps) is not written.
|
||||
/// file, more than 1,000 actions, an action with more than 10,000 steps) is not written, nor is a set
|
||||
/// with a step that sets a folder preference to a folder ([`folder_pref_in`]), which names a folder
|
||||
/// on this computer: the error names the action and the preference.
|
||||
pub fn save_set(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let len = app.ui.action_sets.len();
|
||||
let set = match (p.get("id"), p.get("set")) {
|
||||
@@ -292,6 +318,11 @@ pub fn save_set(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
(_, Some(v)) => v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < len).ok_or("no such action set")?,
|
||||
};
|
||||
let Some(s) = app.ui.action_sets.get(set) else { return Err("no such action set".into()) };
|
||||
let folder =
|
||||
s.actions.iter().find_map(|a| a.steps.iter().find_map(|(command, params)| folder_pref_in(command, params)).map(|key| (&a.name, key)));
|
||||
if let Some((action, key)) = folder {
|
||||
return Err(format!("the action “{}” sets the folder preference {}; delete that step to save the set", action, key));
|
||||
}
|
||||
let text = write_set(s);
|
||||
// What Save Actions… writes, Load Actions… reads back.
|
||||
read_set(&text, "")?;
|
||||
|
||||
@@ -69,6 +69,16 @@ pub(crate) fn document_brushes(st: &DocState) -> Rc<Vec<Shown>> {
|
||||
MEMO.with(|m| m.borrow_mut().get(&st.doc, st.revision, || vectorcraft_brush::library(&st.doc).iter().map(|b| Shown::of(&st.doc, b)).collect()))
|
||||
}
|
||||
|
||||
/// The brushes of `shown` by name: of two with one name, the first, the one a stroke that names
|
||||
/// it draws with and `brush.get` gives.
|
||||
fn by_name(shown: &[Shown]) -> HashMap<&str, &Shown> {
|
||||
let mut map = HashMap::new();
|
||||
for b in shown {
|
||||
map.entry(b.name.as_str()).or_insert(b);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// The cache key of the stroke preview of brush definition `def` in `doc` (`art`: its art): the
|
||||
/// definition and every resource of `doc` the art uses ([`art_resources`]: patterns with their
|
||||
/// options, symbols, swatches and colour groups, gradients, images, styles…), so the preview
|
||||
@@ -298,7 +308,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// definitions and preview keys are the document version's, worked out once.
|
||||
let doc = app.session.active().map(|d| d.doc.clone());
|
||||
let memo = app.session.active().map(document_brushes).unwrap_or_default();
|
||||
let defs: HashMap<&str, &Shown> = memo.iter().map(|b| (b.name.as_str(), b)).collect();
|
||||
let defs = by_name(&memo);
|
||||
let mut clicked: Option<(String, egui::Modifiers)> = None;
|
||||
let mut options: Option<String> = None;
|
||||
let mut moved: Option<Value> = None;
|
||||
|
||||
@@ -821,3 +821,36 @@ fn previews_follow_the_swatches_images_and_pattern_options_their_art_uses() {
|
||||
assert_eq!(change(&mut app, what, f), (brushes, symbols, what.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Of two brushes with one name, the row of that name previews and drags the first, the one
|
||||
/// `brush.get` gives and a stroke that names it draws with. Reading the library numbers the second
|
||||
/// ("Twin 2"), so it gets a row of its own.
|
||||
#[test]
|
||||
fn rows_of_two_brushes_with_one_name_show_the_first() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let mut twins: Vec<Brush> = Vec::new();
|
||||
app.session
|
||||
.edit("Brushes", |d, _| {
|
||||
let mut lib = vectorcraft_brush::library(d);
|
||||
let kinds: Vec<_> = ["Charcoal", "Dots"].iter().map(|n| lib.iter().find(|b| b.name == *n).unwrap().kind.clone()).collect();
|
||||
twins = kinds.into_iter().map(|kind| Brush { name: "Twin".into(), kind }).collect();
|
||||
lib.extend(twins.iter().cloned());
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let rows: Vec<(String, String)> = brushes(&mut app).0.into_iter().filter(|b| b.0.starts_with("Twin")).map(|b| (b.0, b.1)).collect();
|
||||
assert_eq!(rows, [("Twin".to_string(), "art".to_string()), ("Twin 2".to_string(), "scatter".to_string())], "the panel lists both");
|
||||
let got = app.run("brush.get", json!({"name": "Twin"})).unwrap();
|
||||
let memo = document_brushes(app.session.active().unwrap());
|
||||
let shown = by_name(&memo)["Twin"];
|
||||
assert_eq!(shown.ty, "art");
|
||||
assert_eq!(shown.def, got, "the row shows and drags what brush.get gives");
|
||||
// Rows of one name that reach the panel unnumbered still show the first.
|
||||
let st = app.session.active().unwrap();
|
||||
let raw: Vec<Shown> = twins.iter().map(|b| Shown::of(&st.doc, b)).collect();
|
||||
let shown = by_name(&raw)["Twin"];
|
||||
assert_eq!(shown.ty, "art");
|
||||
assert_eq!(shown.def, got, "the first of two rows of one name is the one shown");
|
||||
}
|
||||
|
||||
@@ -625,8 +625,10 @@ fn dialog_or_palette_open(app: &VectorcraftApp) -> bool {
|
||||
|
||||
/// Route Alt+arrows to spacing controls when text objects are selected.
|
||||
fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
if !m.alt {
|
||||
let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)];
|
||||
// The modifiers held when each arrow went down, as `nudge_and_clear` reads them.
|
||||
let pressed = keys.map(|(key, tool_key)| (key, tool_key, ctx.input(|i| press_modifiers(i, key))));
|
||||
if !pressed.iter().any(|(.., m)| m.alt) {
|
||||
return false;
|
||||
}
|
||||
let orientation = app.session.active().and_then(|st| {
|
||||
@@ -644,9 +646,8 @@ fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let Some(vertical) = orientation else {
|
||||
return false;
|
||||
};
|
||||
let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)];
|
||||
for (key, tool_key) in keys {
|
||||
if ctx.input_mut(|i| i.consume_key(m, key)) {
|
||||
for (key, tool_key, m) in pressed {
|
||||
if m.alt && ctx.input_mut(|i| i.consume_key(m, key)) {
|
||||
if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false, vertical) {
|
||||
let by = if m.command { by * 5.0 } else { by };
|
||||
let _ = app.run("type.step", json!({"attribute": attribute, "by": by}));
|
||||
@@ -685,13 +686,27 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo
|
||||
settings_on_a_mac(app, ctx, mac(ctx));
|
||||
}
|
||||
|
||||
/// The modifiers held when `key` last went down in this frame, which can differ from the frame's
|
||||
/// at its end; the frame's when the key did not go down.
|
||||
fn press_modifiers(i: &egui::InputState, key: Key) -> Modifiers {
|
||||
i.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key => Some(*modifiers),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(i.modifiers)
|
||||
}
|
||||
|
||||
/// Apply arrow-key movement and clear selected art when no text editor owns input, unless a Layers
|
||||
/// or Appearance row has Delete/Backspace (`rows_keys`).
|
||||
fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context, rows_keys: bool) {
|
||||
// Arrow nudges (Shift = ×10, Alt = copy).
|
||||
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
|
||||
for (k, dx, dy) in arrows {
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
// The modifiers held when the key went down, as `claimed_tool_keys` reads them.
|
||||
let m = ctx.input(|i| press_modifiers(i, k));
|
||||
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) {
|
||||
let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt}));
|
||||
}
|
||||
@@ -878,8 +893,13 @@ fn claimed_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewIn
|
||||
// The Smart Pen Tool's Tab while it draws a path (the key otherwise hides the panels).
|
||||
(Key::Tab, ToolKey::Tab),
|
||||
] {
|
||||
if !rows_take(k) && ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) {
|
||||
let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view);
|
||||
let pm = ctx.input(|i| press_modifiers(i, k));
|
||||
if !rows_take(k)
|
||||
&& ctx.input(|i| i.key_pressed(k))
|
||||
&& app.session.tool_claims_key_with(tk, crate::canvas::mods(pm, false), view)
|
||||
&& ctx.input_mut(|i| i.consume_key(pm, k))
|
||||
{
|
||||
let r = app.session.tool_key(tk, crate::canvas::mods(pm, false), view);
|
||||
// Keyboard actions can change the Artboard tool's active board too. Keep the
|
||||
// navigator aligned with its valid survivor, as pointer dispatch does.
|
||||
if r.is_ok()
|
||||
|
||||
@@ -2220,6 +2220,15 @@ fn alt_arrows_step_selected_type_object() {
|
||||
assert_eq!(r["runs"][0]["style"]["tracking"], json!(20.0));
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]);
|
||||
|
||||
// Alt released within the frame of the press: the press's Alt still steps the spacing.
|
||||
frame(
|
||||
&mut app,
|
||||
vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT), egui::Event::ModifiersChanged(Modifiers::NONE)],
|
||||
);
|
||||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||||
assert_eq!(r["runs"][0]["style"]["tracking"], json!(40.0));
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)], "the text is not copied");
|
||||
|
||||
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowDown, Modifiers::ALT)]);
|
||||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||||
assert_eq!(r["runs"][0]["style"]["leading"], json!(26.0));
|
||||
@@ -2245,6 +2254,65 @@ fn alt_arrows_step_selected_type_object() {
|
||||
assert_ne!(selected[0], vectorcraft_doc::NodeId(rect), "a non-type object is still copied by Alt+arrow");
|
||||
}
|
||||
|
||||
/// With the Live Paint Bucket, a plain Right picks the next color swatch, while Alt+Right keeps
|
||||
/// copying and nudging the selection, and a Shift+Right released within the same frame is still a
|
||||
/// Shift+Right that picks no swatch.
|
||||
#[test]
|
||||
fn live_paint_bucket_takes_only_plain_arrows_for_swatches() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [rect]})).unwrap();
|
||||
let first = vectorcraft_tools::builder::color_swatches(&app.session.active().unwrap().doc)[0].name.clone();
|
||||
app.session.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap();
|
||||
app.select_tool("livePaintBucket");
|
||||
let fill = app.session.paint.fill.clone();
|
||||
|
||||
frame(&mut app, press(Key::ArrowRight, Modifiers::ALT));
|
||||
assert_eq!(app.session.paint.fill, fill, "Alt+Right picks no swatch");
|
||||
let selected = &app.session.active().unwrap().selection.objects;
|
||||
assert_ne!(selected.as_slice(), [vectorcraft_doc::NodeId(rect)], "Alt+Right copies and nudges the selection");
|
||||
|
||||
let shift_then_released = vec![
|
||||
egui::Event::ModifiersChanged(Modifiers::SHIFT),
|
||||
egui::Event::Key { key: Key::ArrowRight, physical_key: Some(Key::ArrowRight), pressed: true, repeat: false, modifiers: Modifiers::SHIFT },
|
||||
egui::Event::ModifiersChanged(Modifiers::NONE),
|
||||
];
|
||||
frame(&mut app, shift_then_released);
|
||||
assert_eq!(app.session.paint.fill, fill, "Shift+Right picks no swatch");
|
||||
|
||||
frame(&mut app, press(Key::ArrowRight, Modifiers::NONE));
|
||||
assert_ne!(app.session.paint.fill, fill, "a plain Right picks the next swatch");
|
||||
}
|
||||
|
||||
/// An arrow nudge takes Shift and Alt from the key press, also when they are released within the
|
||||
/// same frame: Shift+Right moves 10 points, and Alt+Right copies.
|
||||
#[test]
|
||||
fn arrow_nudge_takes_the_modifiers_of_the_key_press() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
let rect = vectorcraft_doc::NodeId(rect);
|
||||
app.select_tool("selection");
|
||||
app.session.execute("select.set", &json!({"ids": [rect.0]})).unwrap();
|
||||
let released = |modifiers| {
|
||||
vec![
|
||||
egui::Event::ModifiersChanged(modifiers),
|
||||
egui::Event::Key { key: Key::ArrowRight, physical_key: Some(Key::ArrowRight), pressed: true, repeat: false, modifiers },
|
||||
egui::Event::ModifiersChanged(Modifiers::NONE),
|
||||
]
|
||||
};
|
||||
let left = |app: &VectorcraftApp, id| app.session.active().unwrap().doc.bounds_of(&[id], false).unwrap().x0;
|
||||
|
||||
frame(&mut app, released(Modifiers::SHIFT));
|
||||
assert_eq!(left(&app, rect), 10.0, "Shift+Right moves 10 points");
|
||||
|
||||
frame(&mut app, released(Modifiers::ALT));
|
||||
let selected = app.session.active().unwrap().selection.objects.clone();
|
||||
assert_ne!(selected.as_slice(), [rect], "Alt+Right copies");
|
||||
assert_eq!(left(&app, rect), 10.0, "the original stays");
|
||||
}
|
||||
|
||||
/// #793: with a layout without Latin letters the one-key shortcuts go by the key's position:
|
||||
/// the P key typing Persian `ح` chooses the Pen, the V key typing Russian `м` the Selection
|
||||
/// tool. A Latin layout keeps the letter typed: AZERTY's A (the Q key's place) is A.
|
||||
|
||||
@@ -945,6 +945,62 @@ fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` leaves out of a recorded `prefs.set` step the value of a folder preference outside the
|
||||
/// read root, given as `{key, value}` or in `values`, also in a batch step's commands at any depth: a
|
||||
/// bare name or a subfolder (in the working folder, outside the root), a path with spaces around it and
|
||||
/// a `file:` URL. A folder inside the root, an empty value, a scratch disk choice, other preferences and
|
||||
/// another command's params stay, and the UI state is not changed.
|
||||
#[test]
|
||||
fn ui_inspect_leaves_out_folder_preferences_in_recorded_prefs_steps() {
|
||||
let (mut app, tx, base) = confined("inspect-prefs-steps");
|
||||
let (inside, outside) = (s(base.join("in")), s(base.join("outside")));
|
||||
let url = if outside.starts_with('/') { format!("file://{outside}") } else { format!("file:///{}", outside.replace('\\', "/")) };
|
||||
let folders = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
let mut steps: Vec<Value> = folders.iter().map(|k| json!(["prefs.set", {"key": k, "value": "scratch"}])).collect();
|
||||
steps.push(json!(["prefs.set", {"values": folders.iter().map(|k| (k.to_string(), json!("tmp/scratch"))).collect::<serde_json::Map<_, _>>()}]));
|
||||
steps.push(json!(["prefs.set", {"values": {
|
||||
"scratchPrimary": format!(" {outside} "), "fontsFolder": url, "pluginsFolder": format!(" {url}"), "recoveryFolder": "Startup",
|
||||
"templatesFolder": inside, "scratchSecondary": " None ", "uiAccent": "green",
|
||||
}}]));
|
||||
steps.push(json!(["prefs.set", {"key": "scratchPrimary", "value": "Startup"}]));
|
||||
steps.push(json!(["prefs.set", {"key": "recoveryFolder", "value": ""}]));
|
||||
steps.push(json!(["prefs.set", {"key": "fontsFolder", "value": inside}]));
|
||||
let batch = |commands: Value| json!({"commands": commands});
|
||||
let inner = batch(json!([{"command": "prefs.set", "params": {"values": {"scratchSecondary": "tmp/scratch", "uiAccent": "red"}}}]));
|
||||
steps.push(json!([
|
||||
"command.batch",
|
||||
batch(json!([
|
||||
{"command": "prefs.set", "params": {"key": "scratchPrimary", "value": "scratch"}},
|
||||
{"command": "command.batch", "params": inner},
|
||||
]))
|
||||
]));
|
||||
steps.push(json!(["x.y", {"key": "scratchPrimary", "value": "scratch"}]));
|
||||
let set: crate::panels::actions::ActionSet =
|
||||
serde_json::from_value(json!({"name": "set", "actions": [{"name": "prefs", "steps": steps}]})).unwrap();
|
||||
app.ui.action_sets = vec![set];
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let shown = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"];
|
||||
let mut want: Vec<Value> = folders.iter().map(|k| json!(["prefs.set", {"key": k}])).collect();
|
||||
want.push(json!(["prefs.set", {"values": {}}]));
|
||||
want.push(json!(["prefs.set", {"values": {"templatesFolder": inside, "scratchSecondary": " None ", "uiAccent": "green"}}]));
|
||||
want.push(json!(["prefs.set", {"key": "scratchPrimary", "value": "Startup"}]));
|
||||
want.push(json!(["prefs.set", {"key": "recoveryFolder", "value": ""}]));
|
||||
want.push(json!(["prefs.set", {"key": "fontsFolder", "value": inside}]));
|
||||
let inner = batch(json!([{"command": "prefs.set", "params": {"values": {"uiAccent": "red"}}}]));
|
||||
want.push(json!([
|
||||
"command.batch",
|
||||
batch(json!([
|
||||
{"command": "prefs.set", "params": {"key": "scratchPrimary"}},
|
||||
{"command": "command.batch", "params": inner},
|
||||
]))
|
||||
]));
|
||||
want.push(json!(["x.y", {"key": "scratchPrimary", "value": "scratch"}]));
|
||||
assert_eq!(shown, &json!(want), "{r}");
|
||||
assert!(!r.to_string().contains("tmp/scratch") && !r.to_string().contains(&outside.replace('\\', "\\\\")), "{r}");
|
||||
assert_eq!(app.ui.action_sets[0].actions[0].steps[0].1, json!({"key": "fontsFolder", "value": "scratch"}), "the UI state is not changed");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Two libraries loaded from outside the read root under the same file name, only the second of them
|
||||
/// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and
|
||||
/// that id finds it.
|
||||
|
||||
@@ -80,6 +80,14 @@ fn in_screen_mode(ctx: &egui::Context, mode: u8) -> VectorcraftApp {
|
||||
app
|
||||
}
|
||||
|
||||
/// A point on the empty canvas, low enough to miss the Contextual Task Bar under the selection
|
||||
/// (on a narrow canvas its actions wrap onto more rows and can reach the canvas's center).
|
||||
fn open_canvas_point(app: &VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 {
|
||||
let p = app.canvas_rect.unwrap().lerp_inside(egui::vec2(0.5, 0.85));
|
||||
assert!(crate::canvas::task_bar_rect(ctx).is_none_or(|bar| !bar.expand(40.0).contains(p)), "the task bar covers {p:?}");
|
||||
p
|
||||
}
|
||||
|
||||
fn begin_tools_move(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option<[f32; 2]>, egui::Pos2) {
|
||||
app.run("window.panel.float", json!({"panel": "tools", "x": 120, "y": 140})).unwrap();
|
||||
frame(app, ctx, vec![]);
|
||||
@@ -241,7 +249,7 @@ fn navigation_drag_keeps_screen_mode(kind: &str) {
|
||||
_ => panic!("unknown navigation drag"),
|
||||
};
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let start = app.canvas_rect.unwrap().center();
|
||||
let start = open_canvas_point(&app, &ctx);
|
||||
let end = start + egui::vec2(35.0, 20.0);
|
||||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), pointer(start, true)]);
|
||||
@@ -551,7 +559,7 @@ fn held_escape_that_cancels_a_dialog_or_leaves_presentation_skips_a_command_boun
|
||||
fn a_popup_takes_escape_before_full_screen_exits() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = in_screen_mode(&ctx, 2);
|
||||
let pos = app.canvas_rect.unwrap().center();
|
||||
let pos = open_canvas_point(&app, &ctx);
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos), button(true)]);
|
||||
frame(&mut app, &ctx, vec![button(false)]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user