mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
Merge remote-tracking branch 'upstream/main' into fix/ai-type-pattern-paint
# Conflicts: # docs/file-format-parity.md
This commit is contained in:
Generated
+2
-2
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1108,7 +1108,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-vectorcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"serde",
|
||||
|
||||
+2
-2
@@ -21,11 +21,11 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] }
|
||||
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
|
||||
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
# Native Affinity documents (craft-libs#914).
|
||||
craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
|
||||
@@ -1582,7 +1582,7 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
// Even position of the step, then where the easing puts its shape and its colours.
|
||||
let u = j as f64 / (n + 1) as f64;
|
||||
// Easing needs a spine: a blend made without one keeps its steps evenly spaced.
|
||||
let eased = rail.is_some() && !spec.no_spine;
|
||||
let eased = rail.is_some() && spec.eases_along_spine();
|
||||
let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
||||
let mut s = lerp.at_eased(t, tc);
|
||||
if let Some(r) = &rail {
|
||||
|
||||
@@ -119,6 +119,12 @@ impl BlendSpec {
|
||||
pub fn start(&self, i: usize) -> Option<usize> {
|
||||
self.starts.get(i).copied().flatten().map(|a| a as usize)
|
||||
}
|
||||
|
||||
/// Whether [`Self::ease`] moves the steps: the blend has a spine ([`Self::no_spine`] is off)
|
||||
/// and a replaced spine or Align to Path, the cases where the steps are placed along it.
|
||||
pub fn eases_along_spine(&self) -> bool {
|
||||
!self.no_spine && (self.spine.is_some() || self.orientation == BlendOrientation::AlignToPath)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Blend panel's Create Spine setting: whether a new blend gets a spine.
|
||||
|
||||
@@ -258,7 +258,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.roughen",
|
||||
"Roughen…",
|
||||
DT,
|
||||
"{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}",
|
||||
"{size: 0..100 (5; 0 keeps the outline and adds its points along it), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}",
|
||||
json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}),
|
||||
),
|
||||
g(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_pathops::addons::pathtools::insert_at;
|
||||
|
||||
use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points};
|
||||
|
||||
@@ -79,7 +80,8 @@ const MAX_ROUGHEN_POINTS: usize = 2000;
|
||||
|
||||
/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where
|
||||
/// that would make more than `max` anchors, the points are spaced further apart until it doesn't
|
||||
/// (stacked Roughens multiply their points, #3578).
|
||||
/// (stacked Roughens multiply their points, #3578). At size 0 no point moves: each segment is
|
||||
/// split into its pieces, evenly along its length, and the outline stays as it is.
|
||||
pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let size = num(p, "size", 5.0).clamp(0.0, 1e4);
|
||||
let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -89,6 +91,23 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect();
|
||||
let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count()));
|
||||
let mut lens = lens.iter();
|
||||
if amp == 0.0 {
|
||||
// Each segment's pieces, cut at equal lengths as `pathtools::add_points` cuts them.
|
||||
let mut subs = Vec::with_capacity(path.subpaths.len());
|
||||
for sp in &path.subpaths {
|
||||
let mut places = vec![];
|
||||
for (i, &coarse) in (0..sp.segment_count()).zip(&mut lens) {
|
||||
let k = pieces(coarse, spacing, MAX_ROUGHEN_POINTS);
|
||||
let c = sp.segment(i);
|
||||
let len = c.arclen(ROUGHEN_ARCLEN_ACCURACY);
|
||||
if len > 1e-9 && len.is_finite() {
|
||||
places.extend((1..k).map(|j| (i, c.inv_arclen(len * j as f64 / k as f64, ROUGHEN_ARCLEN_ACCURACY))));
|
||||
}
|
||||
}
|
||||
subs.push(insert_at(sp, &places).0);
|
||||
}
|
||||
return PathData::new(subs);
|
||||
}
|
||||
let smooth = smooth_points(p);
|
||||
let seed = seed(p);
|
||||
let mut subs = vec![];
|
||||
@@ -118,6 +137,8 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
PathData::new(subs)
|
||||
}
|
||||
|
||||
const ROUGHEN_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// The move and the number of copies of a Transform effect with params `p` that only moves what
|
||||
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
|
||||
/// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales
|
||||
|
||||
@@ -134,6 +134,10 @@ impl Plan {
|
||||
|
||||
fn fitted(mut segs: Vec<PathSeg>, mut lens: Vec<f64>, closed: bool, period: f64) -> Self {
|
||||
let n = segs.len();
|
||||
// A subpath with no segments (a lone moveto) has no runs to fit.
|
||||
if n == 0 {
|
||||
return Self::exact(segs, lens, closed, period);
|
||||
}
|
||||
// Corners: where segments with length meet at a kink (zero-length ones have no direction).
|
||||
let live: Vec<usize> = (0..n).filter(|&i| lens[i] > EPS).collect();
|
||||
let mut corners: Vec<usize> = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect();
|
||||
|
||||
@@ -37,6 +37,20 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
|
||||
// ---------------------------------------------------------------- dashes fitted to corners
|
||||
|
||||
/// A subpath with no segments (a lone moveto, as an HP-GL wedge of almost no radius gives) is
|
||||
/// dashed fitted to corners without a panic, and the other subpaths keep their dashes.
|
||||
#[test]
|
||||
fn fitted_dashes_take_a_subpath_with_no_segments() {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((5.0, 5.0));
|
||||
b.extend(line(100.0));
|
||||
let d = dash(&b, &fitted(&[10.0, 5.0], 0.0)).expect("dashed");
|
||||
assert!(moves(&d.path) > 1 && length(&d.path) > 0.0, "{:?}", d.path);
|
||||
let mut lone = BezPath::new();
|
||||
lone.move_to((0.0, 0.0));
|
||||
assert!(dash(&lone, &fitted(&[10.0, 5.0], 0.0)).is_some_and(|d| d.path.elements().is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_corner_of_a_rectangle_falls_mid_dash() {
|
||||
// 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3
|
||||
|
||||
@@ -1251,6 +1251,96 @@ fn roughen_smooth_makes_no_cusps() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly() {
|
||||
use vectorcraft_geom::AnchorKind;
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let curve = PathData::single(SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
|
||||
],
|
||||
false,
|
||||
));
|
||||
let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
// Two subpaths with lines and curves: a closed one whose last segment is a line, and an open
|
||||
// one that ends with a line.
|
||||
let (c, d, e, f) = (Point::new(100.0, 100.0), Point::new(200.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 100.0));
|
||||
let mixed = PathData::new(vec![
|
||||
SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::corner(a),
|
||||
vectorcraft_geom::Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(160.0, 20.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(c, Point::new(160.0, 80.0), c),
|
||||
],
|
||||
true,
|
||||
),
|
||||
SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(d, d, Point::new(220.0, 60.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(e, Point::new(280.0, 60.0), e),
|
||||
vectorcraft_geom::Anchor::corner(f),
|
||||
],
|
||||
false,
|
||||
),
|
||||
]);
|
||||
for path in [square(), ellipse, curve, mixed] {
|
||||
let all: Vec<_> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect();
|
||||
let off = |q: Point| all.iter().map(|c| c.nearest(q, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt();
|
||||
for params in
|
||||
[json!({"size": 0}), json!({"size": 0, "points": "corner"}), json!({"size": 0, "relative": false, "points": "corner", "seed": 7})]
|
||||
{
|
||||
let out = run("distort.roughen", params.clone(), &path);
|
||||
assert!(out.anchor_count() > path.anchor_count(), "{params}: the points are added");
|
||||
assert_eq!(out.subpaths.len(), path.subpaths.len(), "{params}");
|
||||
for (sp, got) in path.subpaths.iter().zip(&out.subpaths) {
|
||||
assert_eq!(got.closed, sp.closed);
|
||||
// Walk the result one original segment at a time: its pieces end on the segment's end anchor.
|
||||
let mut at = 0;
|
||||
for i in 0..sp.segment_count() {
|
||||
let (orig, end) = (sp.segment(i), sp.anchors[(i + 1) % sp.anchors.len()]);
|
||||
let start = got.anchors[at];
|
||||
let was = sp.anchors[i];
|
||||
assert_eq!((start.p, start.kind, start.has_in(), start.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}");
|
||||
let mut pieces = vec![];
|
||||
loop {
|
||||
let piece = got.segment(at);
|
||||
for j in 0..=8 {
|
||||
let q = piece.eval(j as f64 / 8.0);
|
||||
assert!(off(q) < 1e-6, "{params}: {q:?} is {} off the outline", off(q));
|
||||
}
|
||||
pieces.push(piece.arclen(1e-6));
|
||||
at += 1;
|
||||
if piece.p3.distance(end.p) < 1e-9 {
|
||||
break;
|
||||
}
|
||||
// A point the effect added: a corner without handles on a line, a smooth
|
||||
// point whose handles follow the curve.
|
||||
let added = got.anchors[at];
|
||||
if sp.segment_is_line(i) {
|
||||
assert_eq!((added.kind, added.has_in(), added.has_out()), (AnchorKind::Corner, false, false), "{params}");
|
||||
} else {
|
||||
assert_eq!(added.kind, AnchorKind::Smooth, "{params}");
|
||||
let (hi, ho) = (added.h_in - added.p, added.h_out - added.p);
|
||||
assert!(hi.cross(ho).abs() < 1e-6 * hi.hypot() * ho.hypot() && hi.dot(ho) < 0.0, "{params}: {added:?}");
|
||||
}
|
||||
}
|
||||
assert!(pieces.len() > 1, "{params}: segment {i} is split");
|
||||
assert!(close(pieces.iter().sum::<f64>(), orig.arclen(1e-6), 1e-3), "{params}: {pieces:?}");
|
||||
let (lo, hi) = pieces.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &l| (lo.min(l), hi.max(l)));
|
||||
assert!(hi - lo < 1e-3, "{params}: segment {i}'s pieces from {lo} to {hi}");
|
||||
}
|
||||
assert_eq!(at, got.segment_count(), "{params}");
|
||||
if !sp.closed {
|
||||
let (last, was) = (got.anchors[at], sp.anchors[sp.anchors.len() - 1]);
|
||||
assert_eq!((last.p, last.kind, last.has_in(), last.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_ridge_count_and_amplitude() {
|
||||
let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square());
|
||||
|
||||
@@ -137,7 +137,7 @@ fn legend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = st.target_parent()?;
|
||||
let text_size = (size / 7.0).clamp(4.0, 12.0);
|
||||
let pad = (size / 12.0).clamp(1.0, 8.0);
|
||||
let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }));
|
||||
let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }), C)?;
|
||||
let count = swatches.len();
|
||||
// Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or
|
||||
// spot swatch then recolours the legend too.
|
||||
|
||||
@@ -139,7 +139,8 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut run = 0;
|
||||
for (i, at) in chars.into_iter().enumerate() {
|
||||
let random = if shape == "random" { unit(&mut rng) } else { 0.0 };
|
||||
let value = from + (to - from) * amount(shape, i, n, random);
|
||||
// Rounding can carry a value a hair past the range's ends.
|
||||
let value = (from + (to - from) * amount(shape, i, n, random)).clamp(lo, hi);
|
||||
let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?;
|
||||
change.localize(&t, C)?;
|
||||
while ends.get(run).is_some_and(|&e| e <= at) {
|
||||
|
||||
@@ -170,6 +170,7 @@ pub struct BuiltinBrushLibrary {
|
||||
/// The built-in brush libraries, in menu order: original brushes generated in code.
|
||||
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
|
||||
BuiltinBrushLibrary { id: "ink-and-wash", name: "Ink and Wash", make: ink_and_wash },
|
||||
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
|
||||
BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes },
|
||||
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
|
||||
@@ -268,6 +269,78 @@ fn art_ribbons() -> BrushLibrary {
|
||||
built("Art Ribbons", doc, brushes)
|
||||
}
|
||||
|
||||
/// A band between the edges y = `top(x)` and y = `bottom(x)` for x in `from`..`to`, in `n` steps.
|
||||
fn between(from: f64, to: f64, n: usize, top: impl Fn(f64) -> f64, bottom: impl Fn(f64) -> f64) -> PathData {
|
||||
let xs: Vec<f64> = (0..=n).map(|i| from + (to - from) * i as f64 / n as f64).collect();
|
||||
let mut pts: Vec<Point> = xs.iter().map(|&x| Point::new(x, top(x))).collect();
|
||||
pts.extend(xs.iter().rev().map(|&x| Point::new(x, bottom(x))));
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
}
|
||||
|
||||
/// Path `p` filled with gray `hex` at `opacity`: one layer of a wash.
|
||||
fn wash(p: PathData, hex: &str, opacity: f32) -> Node {
|
||||
let mut n = filled(p, Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)));
|
||||
n.opacity = opacity;
|
||||
n
|
||||
}
|
||||
|
||||
/// How much of a wash's width is left at x: rounded ends, full width from 8 pt in.
|
||||
fn wash_ends(x: f64) -> f64 {
|
||||
let ends = ((x / 8.0).min(1.0) * ((100.0 - x) / 8.0).min(1.0)).max(0.0);
|
||||
0.4 + 0.6 * ends.sqrt()
|
||||
}
|
||||
|
||||
/// Ink and Wash: Tapered Ink, broad near its start and thinning to a fine tip; Brush Pen, a
|
||||
/// rounded nib whose size follows the pen pressure; and two washes of translucent grays, Soft Wash
|
||||
/// with faint edges and Pooling Wash with a darker rim and two light breaks. Black and grays take
|
||||
/// the stroke color as tints of it.
|
||||
fn ink_and_wash() -> BrushLibrary {
|
||||
let tapered = {
|
||||
let half = |x: f64| 3.0 * (x / 22.0).min(1.0).powf(0.6) * ((100.0 - x) / 78.0).min(1.0).powf(1.4);
|
||||
black(between(0.0, 100.0, 48, |x| -0.85 * half(x), |x| 1.15 * half(x)))
|
||||
};
|
||||
let pen = Brush {
|
||||
name: "Brush Pen".into(),
|
||||
kind: BrushKind::Calligraphic(Calligraphic {
|
||||
angle: 40.0,
|
||||
roundness: 75.0,
|
||||
size: 5.0,
|
||||
variation: [0.0, 0.0, 2.5],
|
||||
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
|
||||
}),
|
||||
};
|
||||
let soft = {
|
||||
let top = |x: f64| -(4.5 + 0.5 * (x * 0.21).sin() + 0.3 * (x * 0.57 + 1.3).sin()) * wash_ends(x);
|
||||
let bottom = |x: f64| (4.5 + 0.45 * (x * 0.26 + 2.1).sin() + 0.3 * (x * 0.69 + 0.4).sin()) * wash_ends(x);
|
||||
group(vec![
|
||||
wash(between(0.0, 100.0, 40, top, bottom), "#404040", 0.4),
|
||||
wash(between(2.0, 98.0, 32, |x| top(x) - 0.7 - 0.3 * (x * 0.43).sin().abs(), |x| top(x) + 0.5), "#404040", 0.2),
|
||||
wash(between(3.0, 97.0, 32, |x| bottom(x) - 0.5, |x| bottom(x) + 0.6 + 0.35 * (x * 0.37 + 1.0).sin().abs()), "#404040", 0.18),
|
||||
wash(shapes::ellipse(Rect::new(21.0, -2.4, 39.0, 0.8)), "#e6e6e6", 0.22),
|
||||
wash(shapes::ellipse(Rect::new(61.0, -0.3, 75.0, 2.3)), "#e6e6e6", 0.18),
|
||||
])
|
||||
};
|
||||
let pooling = {
|
||||
let top = |x: f64| -(4.0 + 0.4 * (x * 0.18 + 0.7).sin() + 0.2 * (x * 0.53).sin()) * wash_ends(x);
|
||||
let bottom = |x: f64| (4.0 + 0.35 * (x * 0.23 + 1.9).sin() + 0.25 * (x * 0.61 + 0.2).sin()) * wash_ends(x);
|
||||
let rim = |x: f64, k: f64, phase: f64| 0.35 + k * (0.5 + 0.5 * (x * 0.29 + phase).sin());
|
||||
group(vec![
|
||||
wash(between(0.0, 100.0, 40, top, bottom), "#4d4d4d", 0.35),
|
||||
wash(between(1.0, 99.0, 40, top, |x| top(x) + rim(x, 0.45, 0.0)), "#262626", 0.55),
|
||||
wash(between(1.0, 99.0, 40, |x| bottom(x) - rim(x, 0.5, 1.1), bottom), "#262626", 0.55),
|
||||
wash(polygon(&[(34.0, -2.6), (39.0, -2.8), (36.0, 2.7), (31.0, 2.5)]), "#e6e6e6", 0.2),
|
||||
wash(polygon(&[(70.0, -2.4), (74.0, -2.5), (72.0, 2.6), (68.0, 2.4)]), "#e6e6e6", 0.18),
|
||||
])
|
||||
};
|
||||
let brushes = vec![
|
||||
art("Tapered Ink", tapered, Colorization::Tints),
|
||||
pen,
|
||||
art("Soft Wash", soft, Colorization::Tints),
|
||||
art("Pooling Wash", pooling, Colorization::Tints),
|
||||
];
|
||||
built("Ink and Wash", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
|
||||
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
|
||||
}
|
||||
|
||||
@@ -491,7 +491,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(C, "the text is empty"));
|
||||
}
|
||||
let style = super::super::create::new_type_style(s, &Value::Null);
|
||||
let style = super::super::create::new_type_style(s, &Value::Null, C)?;
|
||||
let system_layout = s.doc()?.doc.setup.system_layout;
|
||||
let layout = |t: &mut TextObject| {
|
||||
t.system_layout = system_layout;
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt 0.1..1296 (outside it: an error, and no type is made), font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -739,7 +739,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(a, &["width", "height"], "text.create")?;
|
||||
}
|
||||
let text = str_param(p, "text").unwrap_or("");
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p, "text.create")?);
|
||||
// Turned about the point (the first baseline's start, an area's top-left corner): type set
|
||||
// along a Constrain Angle.
|
||||
let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 };
|
||||
@@ -848,11 +848,11 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject)
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
|
||||
/// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults,
|
||||
/// filled with the current fill unless that is None or white (black then), in the document's
|
||||
/// language.
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
/// language. A `size` outside [`super::typecmd::TYPE_SIZES`] is an error.
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value, cmd: &str) -> Result<CharStyle> {
|
||||
let mut style = CharStyle::default();
|
||||
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
|
||||
style.size = sz.clamp(0.1, 1296.0);
|
||||
style.size = super::typecmd::given_type_size(sz, cmd)?;
|
||||
}
|
||||
if let Some(f) = str_param(p, "font") {
|
||||
style.font_family = f.to_string();
|
||||
@@ -873,5 +873,5 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
let chosen = |name: &str| vectorcraft_doc::setup::char_language(name).ok().flatten();
|
||||
let document = s.doc().ok().and_then(|d| chosen(&d.doc.setup.language));
|
||||
style.language = document.or_else(|| chosen(&s.prefs.hyphenation_language)).map(str::to_string);
|
||||
style
|
||||
Ok(style)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,8 @@ mod tests_readonly;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::{Placed, copy_new, file_metadata};
|
||||
pub(crate) use access::{
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file,
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, naming_in_error, read_file, read_file_head, shown_link, shown_path,
|
||||
write_file, write_new_file,
|
||||
};
|
||||
#[cfg(all(test, windows))]
|
||||
pub(crate) use access::{document_path_on, document_path_with};
|
||||
|
||||
@@ -65,6 +65,62 @@ pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::bo
|
||||
std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name })
|
||||
}
|
||||
|
||||
/// What an error names a file or folder outside the read roots by when [`shown_path`] gives no
|
||||
/// name for it, or gives one that is not valid text.
|
||||
pub(crate) const UNNAMED: &str = "a file or folder outside the read roots";
|
||||
|
||||
/// An error about `path`, a file or folder the client did not give, as the request being served
|
||||
/// may see it: where the message names `path` ([`naming_in_message`]), it names it as
|
||||
/// [`shown_path`] does, or as [`UNNAMED`] when that is empty or holds a replacement character. The
|
||||
/// roots the message names and the reason stay whole. Unchanged when `path` is [`shown`].
|
||||
pub(crate) fn naming_in_error(e: EngineError, path: &str) -> EngineError {
|
||||
if path.is_empty() || shown(path) {
|
||||
return e;
|
||||
}
|
||||
naming_as(e, path, &error_name(path))
|
||||
}
|
||||
|
||||
/// What an error names `path` by when it is outside the read roots: [`shown_path`], or
|
||||
/// [`UNNAMED`] when that is empty or holds a replacement character.
|
||||
pub(crate) fn error_name(path: &str) -> String {
|
||||
let name = shown_path(path);
|
||||
if name.is_empty() || name.contains('\u{FFFD}') { UNNAMED.to_string() } else { name.into_owned() }
|
||||
}
|
||||
|
||||
/// [`naming_in_error`] with `path` named `name`, whether or not it is [`shown`] (a name worked out
|
||||
/// earlier, for the request that prepared the work).
|
||||
pub(crate) fn naming_as(e: EngineError, path: &str, name: &str) -> EngineError {
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(naming_in_message(&msg, path, name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: naming_in_message(&msg, path, name) },
|
||||
EngineError::ReadOnlyLocation(msg) => EngineError::ReadOnlyLocation(naming_in_message(&msg, path, name)),
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `path` given as `name` where it begins a path the message is about: at the start of
|
||||
/// `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or after
|
||||
/// `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message names
|
||||
/// stays whole, even one inside `path`.
|
||||
pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(path)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
@@ -466,3 +522,52 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
/// An error about a path the client did not give, under roots that don't let it be read: the
|
||||
/// path is named by its file name (after a `/` or a `\`), or as [`UNNAMED`] when it ends in no
|
||||
/// name or its name is not valid text, and every root and the reason stay whole. A path inside the
|
||||
/// read roots, and any path without roots in force, stay whole.
|
||||
#[test]
|
||||
fn an_error_names_a_path_outside_the_read_roots_by_its_name() {
|
||||
let base = std::env::temp_dir().join(format!("vc-access-naming-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (a, b, out) = (base.join("a"), base.join("b"), base.join("kept-out"));
|
||||
for f in [&a, &b, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let text = |p: &std::path::Path| p.to_string_lossy().into_owned();
|
||||
let roots = AutomationRoots::new(&[a.as_path(), b.as_path()], &[a.as_path(), b.as_path()]).unwrap();
|
||||
let file = text(&out.join("doc.svg"));
|
||||
let raw = confine(roots.as_ref(), || crate::file_access::check_write(&file)).unwrap_err();
|
||||
let shown_root = |p: &std::path::Path| crate::cmd::findfiles::plain(std::fs::canonicalize(p).unwrap()).display().to_string();
|
||||
let (ra, rb) = (shown_root(&a), shown_root(&b));
|
||||
let named = |e: EngineError, path: &str| confine(roots.as_ref(), || naming_in_error(e, path)).to_string();
|
||||
|
||||
assert_eq!(named(EngineError::Other(raw.clone()), &file), format!("automation path rejected: outside the write roots {ra}, {rb}: doc.svg"));
|
||||
let io = named(EngineError::Other(format!("{file}: disk full")), &file);
|
||||
assert_eq!(io, "doc.svg: disk full");
|
||||
let bad = named(EngineError::BadParams { cmd: "x".into(), msg: format!("{file}: not a file") }, &file);
|
||||
assert_eq!(bad, "invalid parameters for `x`: doc.svg: not a file");
|
||||
// A save refused by the file system keeps its type and its code.
|
||||
let read_only = confine(roots.as_ref(), || naming_in_error(EngineError::ReadOnlyLocation(format!("{file}: Permission denied")), &file));
|
||||
assert!(matches!(&read_only, EngineError::ReadOnlyLocation(_)));
|
||||
assert_eq!(read_only.to_string(), format!("{}: doc.svg: Permission denied", crate::READ_ONLY_LOCATION));
|
||||
// A Windows path written with `\`, a path that ends in no name and a name that isn't text.
|
||||
let win = r"C:\Users\me\secret\doc.svg";
|
||||
assert_eq!(named(EngineError::Other(format!("{win}: denied")), win), "doc.svg: denied");
|
||||
let folder = format!("{}/", text(&out));
|
||||
assert_eq!(named(EngineError::Other(format!("{folder}: denied")), &folder), format!("{UNNAMED}: denied"));
|
||||
let lossy = text(&out.join("bad\u{FFFD}.svg"));
|
||||
assert_eq!(named(EngineError::Other(format!("{lossy}: denied")), &lossy), format!("{UNNAMED}: denied"));
|
||||
// Readable, or nothing confined: whole.
|
||||
let inside = text(&a.join("x.svg"));
|
||||
assert_eq!(named(EngineError::Other(format!("{inside}: denied")), &inside), format!("{inside}: denied"));
|
||||
assert_eq!(naming_in_error(EngineError::Other(raw.clone()), &file).to_string(), raw);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1429,6 +1429,42 @@ mod tests {
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
/// A Data Sets batch whose Save step has no path saves the document's own file. When that file is
|
||||
/// outside the client's read and write roots, each row's error keeps the write root and names the
|
||||
/// file by its file name, with Stop on Error on and off. A source folder the client gives stays
|
||||
/// whole in the error.
|
||||
#[test]
|
||||
fn dataset_batch_step_error_hides_document_path() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let base = tmp("roots-error");
|
||||
let (inside, outside) = (base.join("in"), base.join("kept-out"));
|
||||
for f in [&inside, &outside] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let mut s = with_data_sets(&["Alpha", "Beta"]);
|
||||
let own = outside.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||||
s.execute("document.save", &json!({"path": own})).unwrap();
|
||||
let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string();
|
||||
let hidden = outside.to_string_lossy().into_owned();
|
||||
for (stop, rows) in [(true, 1), (false, 2)] {
|
||||
let p = json!({"source": "datasets", "stopOnError": stop, "commands": [{"command": "document.save", "params": {}}]});
|
||||
let r = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap();
|
||||
assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}");
|
||||
for row in r["processed"].as_array().unwrap() {
|
||||
let e = row["error"].as_str().unwrap_or_default();
|
||||
let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft");
|
||||
assert_eq!(e, want, "{r}");
|
||||
}
|
||||
assert!(!r.to_string().contains(&hidden), "{r}");
|
||||
}
|
||||
let p = json!({"source": "folder", "folder": hidden, "commands": [{"command": "object.unlockAll", "params": {}}]});
|
||||
let e = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap_err().to_string();
|
||||
assert!(e.contains(&hidden), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// The names of the files in `dir`, sorted.
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
|
||||
@@ -307,7 +307,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
};
|
||||
let modified = date_param(p, "modified", cmd)?;
|
||||
if let Some(path) = &path {
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd)?;
|
||||
// Without a path, it is the document's own file: the error names it as the request may see it.
|
||||
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?;
|
||||
}
|
||||
let written = written_as(cmd, format, &Value::Object(options.clone()));
|
||||
let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
|
||||
@@ -1056,7 +1058,12 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let mut out = job.write_streamed(&mut super::write_save_file_with)?;
|
||||
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
|
||||
// Without a path, the files written are the document's own file and those beside it.
|
||||
let mut write = |path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
|
||||
super::write_save_file_with(path, fill).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })
|
||||
};
|
||||
let mut out = job.write_streamed(&mut write)?;
|
||||
job.complete(s, uid);
|
||||
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
|
||||
Ok(out)
|
||||
@@ -1111,7 +1118,8 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
|
||||
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
|
||||
let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?;
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
|
||||
doc.template = false;
|
||||
// Back to the saved file: its recovery copy has nothing left to recover.
|
||||
let uid = s.doc()?.uid;
|
||||
|
||||
@@ -649,14 +649,14 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = with_preset(s, p)?;
|
||||
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
|
||||
let mut r = export(s, &p).map_err(|e| match hidden {
|
||||
Some(folder) => leave_out_folder_from_error(e, folder),
|
||||
Some(folder) => super::access::naming_in_error(e, folder),
|
||||
None => e,
|
||||
})?;
|
||||
remember(s, &p);
|
||||
// A prepared export is written later, maybe on another thread: it names the folder as this
|
||||
// request may see it.
|
||||
if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) {
|
||||
export.hidden = Some((folder.to_string(), super::access::shown_path(folder).into_owned()));
|
||||
export.hidden = Some((folder.to_string(), super::access::error_name(folder)));
|
||||
}
|
||||
if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
|
||||
leave_out_unreadable_folder(&mut r, folder);
|
||||
@@ -664,45 +664,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// An error of an export into `folder`, a folder outside the read roots that the client did not
|
||||
/// give: `folder` as the path the error is about names it by its own name
|
||||
/// ([`super::access::shown_path`]), and the rest of the message stays.
|
||||
fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError {
|
||||
leave_out_folder_named(e, folder, &super::access::shown_path(folder))
|
||||
}
|
||||
|
||||
/// [`leave_out_folder_from_error`] with `folder` named `name`.
|
||||
fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError {
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, name) },
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start
|
||||
/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or
|
||||
/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message
|
||||
/// names stays whole, even one inside `folder`.
|
||||
fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(folder)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being
|
||||
/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's
|
||||
/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`),
|
||||
@@ -1162,7 +1123,7 @@ impl ScreenExport {
|
||||
let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) });
|
||||
match hidden {
|
||||
Some((folder, name)) => {
|
||||
let mut r = r.map_err(|e| leave_out_folder_named(e, &folder, &name))?;
|
||||
let mut r = r.map_err(|e| super::access::naming_as(e, &folder, &name))?;
|
||||
leave_out_folder(&mut r, &folder);
|
||||
Ok(r)
|
||||
}
|
||||
@@ -1516,7 +1477,8 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name};
|
||||
use super::super::access::naming_in_message;
|
||||
use super::{Output, ScreenSize, safe_file_name};
|
||||
|
||||
/// A hidden export folder is named by its name where a message is about it, and a root that
|
||||
/// starts with the same text, or a longer path, stays whole.
|
||||
@@ -1533,7 +1495,7 @@ mod tests {
|
||||
("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"),
|
||||
];
|
||||
for (msg, want) in cases {
|
||||
assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}");
|
||||
assert_eq!(naming_in_message(msg, f, "out"), want, "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Relink",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -1337,7 +1337,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")),
|
||||
(true, true) => {
|
||||
let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?;
|
||||
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?)
|
||||
// The document's path is not the client's: the error names it as the request may see it.
|
||||
let no_folder = || fileio::naming_in_error(bad(C, format!("{doc} has no folder")), &doc);
|
||||
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?)
|
||||
}
|
||||
};
|
||||
// An image or a placed document, with its link.
|
||||
@@ -1472,6 +1474,12 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
not_found.dedup();
|
||||
let mut out = json!({ "relinked": relinked, "notFound": not_found });
|
||||
if copy_to.is_some() {
|
||||
// The copies are in the document's folder, which the client did not give.
|
||||
for pair in copied.iter_mut().chain(kept.iter_mut()) {
|
||||
if let Some(to) = pair.get("to").and_then(Value::as_str).map(str::to_string) {
|
||||
pair["to"] = json!(fileio::shown_path(&to));
|
||||
}
|
||||
}
|
||||
out["copied"] = json!(copied);
|
||||
out["kept"] = json!(kept);
|
||||
}
|
||||
@@ -1521,9 +1529,14 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo
|
||||
use std::io::Write as _;
|
||||
let source = Path::new(from);
|
||||
let name = source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// Each name it takes is checked against the automation roots in force.
|
||||
// Each name it takes is checked against the automation roots in force. The folder is the
|
||||
// document's, so a refusal names it and the file as the request may see them.
|
||||
let create = |to: &Path| {
|
||||
crate::file_access::check_write(&to.to_string_lossy()).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
let to_text = to.to_string_lossy();
|
||||
crate::file_access::check_write(&to_text).map_err(|e| {
|
||||
let e = fileio::naming_in_error(EngineError::Other(e), &to_text);
|
||||
std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string())
|
||||
})?;
|
||||
vectorcraft_format::write_new_with(to, |f| f.write_all(bytes))
|
||||
};
|
||||
// A file there that automation may not read isn't taken for the same file.
|
||||
@@ -1534,7 +1547,10 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo
|
||||
Ok(Some(fileio::Placed::Copied(to))) => Ok((to.to_string_lossy().into_owned(), true)),
|
||||
Ok(Some(fileio::Placed::Kept(to))) => Ok((to.to_string_lossy().into_owned(), false)),
|
||||
Ok(None) => Err(EngineError::Other(format!("every numbered name for {name} is taken in the document's folder"))),
|
||||
Err(e) => Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
Err(e) => {
|
||||
let dir = dir.to_string_lossy();
|
||||
Err(fileio::naming_in_error(EngineError::Other(format!("{dir}: {e}")), &dir))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -389,6 +389,39 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn blend_set_easing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.setEasing";
|
||||
let id = match crate::cmd::object_param(p, "id", C)? {
|
||||
Some(id) => id,
|
||||
None => *selected_of(s, is_blend).first().ok_or_else(|| bad(C, "select a blend or give `id`"))?,
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let Some(NodeKind::Blend { children, spec }) = doc.node(id).map(|n| &n.kind) else { return Err(bad(C, "not a blend")) };
|
||||
if !doc.is_editable(id) {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
let ease = ease_param(p, C, spec.ease)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", spec.color_shift, -100.0)?;
|
||||
// Easing moves the steps along a spine, so it needs one with a length to run along.
|
||||
let easing = ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null()));
|
||||
let spined = spec.eases_along_spine()
|
||||
&& live::blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)).is_some_and(|sp| sp.length().is_finite() && sp.length() > 0.0);
|
||||
if easing && !spined {
|
||||
return Err(bad(C, "the blend has no spine"));
|
||||
}
|
||||
let changed = ease != spec.ease || color_shift != spec.color_shift;
|
||||
if changed {
|
||||
s.edit("Blend Options", |d, _| {
|
||||
if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
spec.ease = ease;
|
||||
spec.color_shift = color_shift;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "id": id.0, "easingType": ease.kind.id(), "ramp": ease.ramp * 100.0, "colorShift": color_shift, "changed": changed }))
|
||||
}
|
||||
|
||||
/// Spacing and orientation as `object.blend.options` takes them; the step count and distance
|
||||
/// not in use show their defaults.
|
||||
fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::blend::{
|
||||
blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine,
|
||||
blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
|
||||
blend_reverse_stack, blend_set_easing, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
|
||||
};
|
||||
use super::common::{has_blend, has_envelope, has_mesh};
|
||||
use super::envelope::{
|
||||
@@ -49,6 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
blend_create_spine
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.setEasing",
|
||||
"Blend Easing",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path); a blend made without a spine takes them once Replace Spine has given it one; out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}",
|
||||
has_doc,
|
||||
blend_set_easing
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.expand",
|
||||
"Expand",
|
||||
|
||||
@@ -242,6 +242,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"object.blend.replaceSpine",
|
||||
"object.blend.reverseFrontToBack",
|
||||
"object.blend.reverseSpine",
|
||||
"object.blend.setEasing",
|
||||
"object.blend.spine.addAnchor",
|
||||
"object.blend.spine.convertAnchor",
|
||||
"object.blend.spine.moveAnchor",
|
||||
|
||||
@@ -1276,15 +1276,16 @@ enum Owner {
|
||||
/// dropped) and, with `take` in a store without locks, taken over with a fresh heartbeat, so
|
||||
/// another app leaves it alone meanwhile. In a store without locks an area is gone only when
|
||||
/// nothing says its app runs: no announcement (or the store can't tell) and no heartbeat for three
|
||||
/// intervals but the one this session wrote taking it over.
|
||||
fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<Hold>, take: bool) -> Owner {
|
||||
/// intervals but the one this session wrote taking it over. `held` names the gone apps' areas the
|
||||
/// caller already holds.
|
||||
fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<Hold>, take: bool, held: &[&str]) -> Owner {
|
||||
if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) {
|
||||
return Owner::Mine;
|
||||
}
|
||||
match store.lock(area) {
|
||||
Ok(Lock::Held(h)) => {
|
||||
// Copies an app left here when it had to leave the area stay its own while it runs.
|
||||
if let Some(who) = keeper(s, store, area) {
|
||||
if let Some(who) = keeper(s, store, area, held) {
|
||||
return who;
|
||||
}
|
||||
hold.push(h);
|
||||
@@ -1295,7 +1296,7 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
|
||||
if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) {
|
||||
return Owner::Running;
|
||||
}
|
||||
if let Some(who) = keeper(s, store, area) {
|
||||
if let Some(who) = keeper(s, store, area, held) {
|
||||
return who;
|
||||
}
|
||||
// Best effort: without it another app might offer the copies too, which loses nothing.
|
||||
@@ -1311,12 +1312,14 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
|
||||
/// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`:
|
||||
/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is
|
||||
/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be
|
||||
/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing).
|
||||
fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> {
|
||||
/// checked, keeps nothing. So does an entry in one of the gone apps' areas the caller holds
|
||||
/// (`held`), whose lock this session would otherwise find taken. A store that can't be listed now
|
||||
/// keeps them (a copy kept loses nothing).
|
||||
fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str, held: &[&str]) -> Option<Owner> {
|
||||
let Ok(names) = store.list() else { return Some(Owner::Running) };
|
||||
let mark = format!("/{KEEPS}{area}");
|
||||
let mut who = None;
|
||||
for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) {
|
||||
for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area && !held.contains(k)) {
|
||||
match holder(s, store, k) {
|
||||
Owner::Mine => return Some(Owner::Mine),
|
||||
Owner::Running => who = Some(Owner::Running),
|
||||
@@ -1378,7 +1381,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut rows = vec![];
|
||||
for area in store.areas().map_err(EngineError::Other)? {
|
||||
let mut hold = vec![];
|
||||
let who = owner(s, store.as_ref(), &area, &mut hold, false);
|
||||
let who = owner(s, store.as_ref(), &area, &mut hold, false, &[]);
|
||||
let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect();
|
||||
for f in &files {
|
||||
let meta = read_meta(store.as_ref(), f);
|
||||
@@ -1418,10 +1421,11 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) ->
|
||||
}
|
||||
let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c);
|
||||
let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect();
|
||||
let mut out = vec![];
|
||||
let mut out: Vec<Target> = vec![];
|
||||
for area in areas {
|
||||
let mut hold = vec![];
|
||||
match (owner(s, store, area, &mut hold, true), file) {
|
||||
let held: Vec<&str> = out.iter().map(|(a, _, _)| a.as_str()).collect();
|
||||
match (owner(s, store, area, &mut hold, true, &held), file) {
|
||||
(Owner::Gone, _) => {
|
||||
let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect();
|
||||
out.push((area.to_string(), files, hold));
|
||||
|
||||
@@ -297,7 +297,8 @@ fn list_installed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}));
|
||||
};
|
||||
let folder = root.to_string_lossy();
|
||||
crate::file_access::check_read(&folder).map_err(EngineError::Other)?;
|
||||
// The Scripts folder is the app's: a refusal names it as the request may see it.
|
||||
crate::file_access::check_read(&folder).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), &folder))?;
|
||||
let listing = script::folder::list(root);
|
||||
let scripts: Vec<Value> = listing
|
||||
.scripts()
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy to User Defined",
|
||||
["Window", "Swatch Libraries"],
|
||||
None,
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}",
|
||||
has_user_folder,
|
||||
copy_to_user
|
||||
),
|
||||
@@ -368,7 +368,9 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
|
||||
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
|
||||
Some(p) => {
|
||||
// Outside the request's read roots: the error names no path.
|
||||
// The file's bytes are read: outside the request's read roots, the error names no
|
||||
// path. A document's swatches (the arm below) are written from the loaded library,
|
||||
// and the document is not read again.
|
||||
if !readable(&p) {
|
||||
return Err(bad(cmd, "the library's file is outside the read roots"));
|
||||
}
|
||||
@@ -469,19 +471,34 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// The file name a concealed library or path is shown by: the last part of `path` as this platform
|
||||
/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute
|
||||
/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one
|
||||
/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part.
|
||||
pub fn concealed_file_name(path: &str) -> String {
|
||||
let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten();
|
||||
native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
|
||||
/// roots in force.
|
||||
/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is
|
||||
/// never readable under a read root.
|
||||
fn readable(path: &str) -> bool {
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
!crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each
|
||||
/// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is
|
||||
/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then
|
||||
/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The
|
||||
/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows
|
||||
/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none.
|
||||
/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute
|
||||
/// path counts as one the request may not read. The ids are those of the
|
||||
/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers
|
||||
/// to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library
|
||||
/// keeps the id it was first shown under for the rest of the session, so an id a client was given
|
||||
/// finds the same library after another one with the same file name is loaded; only a library that
|
||||
/// shows that id itself (one loaded later without a file under that name) moves it to the next free
|
||||
/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of
|
||||
/// their full ids, then `others`, in the same order. Without roots in force, none.
|
||||
pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return vec![];
|
||||
@@ -493,7 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
|
||||
let mut taken = BTreeSet::new();
|
||||
for ids in [session, others] {
|
||||
for id in ids {
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path));
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path));
|
||||
match name {
|
||||
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
|
||||
_ => {
|
||||
@@ -502,16 +519,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut kept = vec![None; hidden.len()];
|
||||
for (slot, (id, _)) in kept.iter_mut().zip(&hidden) {
|
||||
if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) {
|
||||
taken.insert(shown.clone());
|
||||
*slot = Some(shown.clone());
|
||||
}
|
||||
}
|
||||
// A new id is never one given to another library earlier in the session.
|
||||
let mut given: BTreeSet<String> = assigned.values().cloned().collect();
|
||||
hidden
|
||||
.into_iter()
|
||||
.map(|(id, name)| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
.zip(kept)
|
||||
.map(|((id, name), kept)| {
|
||||
let shown = kept.unwrap_or_else(|| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) || given.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
given.insert(shown.clone());
|
||||
assigned.insert(id.clone(), shown.clone());
|
||||
shown
|
||||
});
|
||||
(id, shown)
|
||||
})
|
||||
.collect()
|
||||
@@ -746,8 +779,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"user" => (info, lib, false),
|
||||
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
|
||||
};
|
||||
let path = s.swatch_libraries.shown_path(&info.id);
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
|
||||
let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied});
|
||||
// As the saves with `user: true`: no `path` when the request may not read the file.
|
||||
if let Some(path) = s.swatch_libraries.shown_path(&info.id) {
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -715,6 +715,20 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The type sizes a command takes, in points.
|
||||
pub(crate) const TYPE_SIZES: (f64, f64) = (0.1, 1296.0);
|
||||
|
||||
/// A type size `size` that a command was given: an error naming the range when it is outside
|
||||
/// [`TYPE_SIZES`].
|
||||
pub(crate) fn given_type_size(size: f64, cmd: &str) -> Result<f64> {
|
||||
let (lo, hi) = TYPE_SIZES;
|
||||
if !(lo..=hi).contains(&size) {
|
||||
let key = "size";
|
||||
return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}")));
|
||||
}
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
|
||||
/// the local result would make small sizes impossible after enlarging an object.
|
||||
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
|
||||
@@ -820,10 +834,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setStyle";
|
||||
let font = text_param(p, "font", C)?.map(str::to_string);
|
||||
let style = text_param(p, "style", C)?.map(str::to_string);
|
||||
let size = length_given(p, "size", C)?;
|
||||
if size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
let size = length_given(p, "size", C)?.map(|v| given_type_size(v, C)).transpose()?;
|
||||
let leading = match p.get("leading") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
|
||||
|
||||
@@ -215,6 +215,20 @@ pub fn check_read(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Read))
|
||||
}
|
||||
|
||||
/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one
|
||||
/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder.
|
||||
pub fn written_elsewhere(path: &str) -> bool {
|
||||
let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
|
||||
!cfg!(windows) && foreign && current().is_some()
|
||||
}
|
||||
|
||||
/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`,
|
||||
/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
|
||||
/// `destinationFolder`)?
|
||||
pub fn is_path_key(key: &str) -> bool {
|
||||
matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force.
|
||||
pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))?;
|
||||
|
||||
@@ -151,6 +151,9 @@ pub struct Session {
|
||||
pub symbol_libraries: cmd::symbollib::Libraries,
|
||||
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
|
||||
pub brush_libraries: cmd::brushlib::Libraries,
|
||||
/// The id each loaded library outside a request's read roots has been shown under, by its own id
|
||||
/// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session.
|
||||
shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>,
|
||||
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
|
||||
pub libraries: cmd::library::Libraries,
|
||||
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
|
||||
|
||||
@@ -80,6 +80,7 @@ impl Session {
|
||||
style_libraries: Default::default(),
|
||||
symbol_libraries: Default::default(),
|
||||
brush_libraries: Default::default(),
|
||||
shown_library_ids: Default::default(),
|
||||
libraries: Default::default(),
|
||||
pdf_preset_dir: None,
|
||||
dictionary_file: None,
|
||||
|
||||
@@ -405,6 +405,143 @@ fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse(
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.blend.setEasing` sets one blend's easing and color shift, records no undo step when
|
||||
/// nothing changes, and refuses bad values, a blend whose steps follow no spine (easing only), a
|
||||
/// hidden or locked blend and a target that is not a blend, leaving the blend and the history alone.
|
||||
#[test]
|
||||
fn set_easing_edits_one_blend_and_refuses_what_it_cannot_set() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0));
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
|
||||
let depth = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let d0 = depth(&s);
|
||||
// Align to Page along the straight lines between the keys: the steps follow no spine.
|
||||
for bad in [json!({"ramp": 30}), json!({"easingType": "easeOut"})] {
|
||||
let e = s.execute("object.blend.setEasing", &bad).unwrap_err().to_string();
|
||||
assert!(e.contains("the blend has no spine"), "{e}");
|
||||
}
|
||||
assert_eq!(depth(&s), d0);
|
||||
let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap();
|
||||
assert_eq!(r, json!({"id": g.0, "easingType": "easeIn", "ramp": 0.0, "colorShift": 25.0, "changed": true}));
|
||||
assert_eq!((spec(&s, g).1.color_shift, depth(&s)), (25.0, d0 + 1));
|
||||
let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap();
|
||||
assert_eq!((&r["changed"], depth(&s)), (&json!(false), d0 + 1), "an unchanged value records no undo step");
|
||||
// Along the spine each of the four types keeps its kind through a ramp change and its undo.
|
||||
s.execute("object.blend.options", &json!({"orientation": "path"})).unwrap();
|
||||
for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] {
|
||||
s.execute("object.blend.setEasing", &json!({"easingType": kind, "ramp": 60})).unwrap();
|
||||
let r = s.execute("object.blend.setEasing", &json!({"ramp": 20})).unwrap();
|
||||
assert_eq!((&r["easingType"], &r["ramp"], &r["colorShift"]), (&json!(kind), &json!(20.0), &json!(25.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let sp = spec(&s, g).1;
|
||||
assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift), (kind, 0.6, 25.0));
|
||||
}
|
||||
let before = spec(&s, g).1;
|
||||
let d1 = depth(&s);
|
||||
for bad in [
|
||||
json!({"ramp": 101}),
|
||||
json!({"ramp": -1}),
|
||||
json!({"ramp": "x"}),
|
||||
json!({"colorShift": 1e308}),
|
||||
json!({"colorShift": -101}),
|
||||
json!({"easingType": "sideways"}),
|
||||
json!({"id": "abc", "ramp": 10}),
|
||||
json!({"id": a.0, "ramp": 10}),
|
||||
] {
|
||||
assert!(s.execute("object.blend.setEasing", &bad).is_err(), "{bad}");
|
||||
assert_eq!((spec(&s, g).1, depth(&s)), (before.clone(), d1), "{bad}");
|
||||
}
|
||||
// An explicit id works with nothing selected; nothing selected and no id is an error.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("object.blend.setEasing", &json!({"ramp": 10})).is_err());
|
||||
s.execute("object.blend.setEasing", &json!({"id": g.0, "ramp": 10})).unwrap();
|
||||
assert_eq!(spec(&s, g).1.ease.ramp, 0.1);
|
||||
// A hidden or locked blend is refused.
|
||||
for hide in [true, false] {
|
||||
s.edit("state", |d, _| {
|
||||
let n = d.node_mut(g).unwrap();
|
||||
(n.visible, n.locked) = (!hide, !hide);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (before, d2) = (spec(&s, g).1, depth(&s));
|
||||
let e = s.execute("object.blend.setEasing", &json!({"id": g.0, "colorShift": 5})).unwrap_err().to_string();
|
||||
assert!(e.contains("the object is hidden or locked"), "{e}");
|
||||
assert_eq!((spec(&s, g).1, depth(&s)), (before, d2));
|
||||
}
|
||||
// A blend made without a spine takes no easing, Align to Path included; its color shift still
|
||||
// changes.
|
||||
let (c, d) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0));
|
||||
sel(&mut s, &[c, d]);
|
||||
let n = id_of(&s.execute("object.blend.make", &json!({"createSpine": "never", "orientation": "path"})).unwrap());
|
||||
let d3 = depth(&s);
|
||||
let e = s.execute("object.blend.setEasing", &json!({"id": n.0, "ramp": 30})).unwrap_err().to_string();
|
||||
assert!(e.contains("the blend has no spine"), "{e}");
|
||||
assert_eq!(depth(&s), d3);
|
||||
s.execute("object.blend.setEasing", &json!({"id": n.0, "colorShift": 10})).unwrap();
|
||||
assert_eq!(spec(&s, n).1.color_shift, 10.0);
|
||||
// Replace Spine gives it a spine, and it takes easing from then on.
|
||||
let path = id_of(&s.execute("path.create", &json!({"d": "M0 320 L200 320"})).unwrap());
|
||||
sel(&mut s, &[n, path]);
|
||||
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
|
||||
let r = s.execute("object.blend.setEasing", &json!({"id": n.0, "easingType": "easeOut", "ramp": 30})).unwrap();
|
||||
assert_eq!((&r["easingType"], &r["ramp"], &r["changed"]), (&json!("easeOut"), &json!(30.0), &json!(true)));
|
||||
}
|
||||
|
||||
/// Dragging a blend widget with the Selection tool or the Blend tool is one undo step; a click, a
|
||||
/// drag that ends at its starting value and a drag canceled with Escape record none, and Escape
|
||||
/// puts the value back.
|
||||
#[test]
|
||||
fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0));
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "orientation": "path", "easingType": "easeOut"})).unwrap());
|
||||
let v = ViewInfo::default();
|
||||
let depth = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
|
||||
};
|
||||
s.select_tool("selection", v).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
let d0 = depth(&s);
|
||||
// The Easing widget sits 16 px below the spine's halfway point (120, 20).
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 135.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 150.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Up, 150.0, 36.0);
|
||||
let sp = spec(&s, g).1;
|
||||
assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, 0.0, d0 + 1));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g], "the blend stays selected");
|
||||
// A click, and a drag back to where it started.
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Up, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 160.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Up, 120.0, 36.0);
|
||||
assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1));
|
||||
// Escape during a drag puts the value back.
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 170.0, 36.0);
|
||||
assert_eq!(spec(&s, g).1.ease.ramp, 0.8, "the drag previews");
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
ptr(&mut s, PointerKind::Up, 170.0, 36.0);
|
||||
assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1));
|
||||
// The Blend tool drags the Appearance widget, 16 px above: the color shift alone.
|
||||
s.select_tool("blend", v).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 4.0);
|
||||
ptr(&mut s, PointerKind::Drag, 100.0, 4.0);
|
||||
ptr(&mut s, PointerKind::Up, 100.0, 4.0);
|
||||
let sp = spec(&s, g).1;
|
||||
assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, -20.0, d0 + 2));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, g).1.color_shift, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_blend_easing_values_clamp_and_non_finite_become_default() {
|
||||
for (bad, ramp, shift) in [
|
||||
|
||||
@@ -106,7 +106,17 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"]
|
||||
[
|
||||
"art-ribbons",
|
||||
"ink-and-wash",
|
||||
"calligraphic-nibs",
|
||||
"bristle-brushes",
|
||||
"pattern-borders",
|
||||
"scatter-shapes",
|
||||
"game-trails",
|
||||
"energy-beams",
|
||||
"dry-media"
|
||||
]
|
||||
);
|
||||
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
|
||||
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
|
||||
@@ -120,26 +130,28 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
|
||||
["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"]
|
||||
);
|
||||
assert_eq!(libs[5]["count"].as_u64(), Some(4));
|
||||
assert_eq!(libs[6]["count"].as_u64(), Some(4));
|
||||
let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"}));
|
||||
assert_eq!(
|
||||
beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
|
||||
["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"]
|
||||
);
|
||||
assert_eq!(libs[6]["count"].as_u64(), Some(4));
|
||||
assert_eq!(libs[7]["count"].as_u64(), Some(4));
|
||||
let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"}));
|
||||
assert_eq!(
|
||||
dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::<Vec<_>>(),
|
||||
[("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")]
|
||||
);
|
||||
assert_eq!((libs[7]["name"].as_str(), libs[7]["count"].as_u64()), (Some("Dry Media"), Some(4)));
|
||||
assert_eq!((libs[8]["name"].as_str(), libs[8]["count"].as_u64()), (Some("Dry Media"), Some(4)));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap();
|
||||
let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]}));
|
||||
assert_eq!(strs(&added["added"]), ["Laser Bolt"]);
|
||||
// One kind of brush per library, every brush drawing something.
|
||||
// One kind of brush per library (Ink and Wash: art brushes and a calligraphic one), every
|
||||
// brush drawing something.
|
||||
for (id, kind) in [
|
||||
("art-ribbons", "art"),
|
||||
("ink-and-wash", "art"),
|
||||
("calligraphic-nibs", "calligraphic"),
|
||||
("bristle-brushes", "bristle"),
|
||||
("pattern-borders", "pattern"),
|
||||
@@ -151,6 +163,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
let (_, lib) = cmd::brushlib::library(&s, id).unwrap();
|
||||
assert!(lib.len() >= 4, "{id}");
|
||||
for b in &lib.brushes {
|
||||
let kind = if b.name == "Brush Pen" { "calligraphic" } else { kind };
|
||||
assert_eq!(b.kind.type_id(), kind, "{}", b.name);
|
||||
let mut line = vectorcraft_geom::BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
@@ -176,6 +189,153 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Every path of brush art `n`: (anchors, opacity).
|
||||
fn art_paths(n: &vectorcraft_doc::Node) -> Vec<(usize, f32)> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if let Some(p) = c.path_data() {
|
||||
out.push((p.anchor_count(), c.opacity));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The Ink and Wash library, made once.
|
||||
fn inks(s: &Session) -> std::sync::Arc<cmd::brushlib::BrushLibrary> {
|
||||
cmd::brushlib::library(s, "ink-and-wash").unwrap().1
|
||||
}
|
||||
|
||||
/// A straight line from the origin to (`x`, 0).
|
||||
fn line_to(x: f64) -> vectorcraft_geom::BezPath {
|
||||
let mut line = vectorcraft_geom::BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((x, 0.0));
|
||||
line
|
||||
}
|
||||
|
||||
fn black_stroke() -> vectorcraft_doc::StrokeLayer {
|
||||
vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0)
|
||||
}
|
||||
|
||||
/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two
|
||||
/// translucent washes, with names of their own and a few paths of art each.
|
||||
#[test]
|
||||
fn the_ink_and_wash_library_has_tapered_ink_a_brush_pen_and_two_washes() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "brush.library", json!({"library": "ink-and-wash"}));
|
||||
assert_eq!((lib["name"].as_str(), lib["category"].as_str()), (Some("Ink and Wash"), Some("builtIn")));
|
||||
let listed: Vec<(&str, &str)> =
|
||||
lib["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect();
|
||||
assert_eq!(listed, [("Tapered Ink", "art"), ("Brush Pen", "calligraphic"), ("Soft Wash", "art"), ("Pooling Wash", "art")]);
|
||||
assert_eq!(run(&mut s, "brush.library", json!({"library": "Ink and Wash"}))["id"], "ink-and-wash");
|
||||
let inks = inks(&s);
|
||||
// Names no other built-in library uses.
|
||||
for b in cmd::brushlib::BUILTIN.iter().filter(|b| b.id != "ink-and-wash") {
|
||||
let (_, other) = cmd::brushlib::library(&s, b.id).unwrap();
|
||||
assert!(inks.brushes.iter().all(|x| other.brush(&x.name).is_none()), "{}", b.id);
|
||||
}
|
||||
// The art: within its 100 pt length, a few paths each, opaque ink and translucent washes.
|
||||
let mut anchors = 0;
|
||||
for name in ["Tapered Ink", "Soft Wash", "Pooling Wash"] {
|
||||
let BrushKind::Art(a) = &inks.brush(name).unwrap().kind else { panic!("{name} is not an art brush") };
|
||||
assert_eq!((a.scale, a.colorization), (vectorcraft_brush::ArtScale::Stretch, vectorcraft_brush::Colorization::Tints), "{name}");
|
||||
let b = a.art.geometric_bounds().unwrap();
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{name}");
|
||||
assert!(b.x0 >= 0.0 && b.x1 <= 100.0 && b.x1 - b.x0 > 99.0, "{name}: {b:?}");
|
||||
assert!(b.y0 >= -8.0 && b.y1 <= 8.0 && b.y1 - b.y0 > 5.0, "{name}: {b:?}");
|
||||
let paths = art_paths(&a.art);
|
||||
let count: usize = paths.iter().map(|p| p.0).sum();
|
||||
assert!(paths.len() <= 80 && count <= 500, "{name}: {} paths, {count} anchors", paths.len());
|
||||
anchors += count;
|
||||
let opacities: Vec<f32> = paths.iter().map(|p| p.1).collect();
|
||||
if name == "Tapered Ink" {
|
||||
assert_eq!(opacities, [1.0], "opaque ink");
|
||||
} else {
|
||||
assert!(paths.len() >= 3, "{name}: a wash of several layers");
|
||||
assert!(opacities.iter().all(|o| (0.15..=0.55).contains(o)), "{name}: {opacities:?}");
|
||||
}
|
||||
}
|
||||
assert!(anchors <= 1200, "{anchors} anchors");
|
||||
let BrushKind::Calligraphic(pen) = &inks.brush("Brush Pen").unwrap().kind else { panic!("Brush Pen is not calligraphic") };
|
||||
use vectorcraft_brush::Variation::{Fixed, Pressure};
|
||||
assert_eq!((pen.size, pen.modes(), pen.variation), (5.0, [Fixed, Fixed, Pressure], [0.0, 0.0, 2.5]), "no random variation");
|
||||
}
|
||||
|
||||
/// Every Ink and Wash brush draws along straight, curved, short and tight paths within finite
|
||||
/// bounds, and Tapered Ink is broad near its start and fine at its end.
|
||||
#[test]
|
||||
fn ink_and_wash_brushes_draw_along_straight_curved_short_and_tight_paths() {
|
||||
let inks = inks(&Session::new());
|
||||
let mut curve = vectorcraft_geom::BezPath::new();
|
||||
curve.move_to((0.0, 0.0));
|
||||
curve.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0));
|
||||
let tight = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 6.0, 6.0)).to_bezpath();
|
||||
let paths = [line_to(200.0), line_to(3.0), curve, tight];
|
||||
for b in &inks.brushes {
|
||||
for line in &paths {
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, b, line, &black_stroke());
|
||||
assert!(!pieces.is_empty(), "{} draws nothing", b.name);
|
||||
let bounds: Vec<_> = pieces.iter().map(|p| p.geometric_bounds()).collect();
|
||||
assert!(bounds.iter().all(|r| r.is_some_and(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))), "{}", b.name);
|
||||
}
|
||||
}
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, inks.brush("Tapered Ink").unwrap(), &line_to(100.0), &black_stroke());
|
||||
let widest = |from: f64, to: f64| {
|
||||
pieces
|
||||
.iter()
|
||||
.filter_map(|n| n.path_data())
|
||||
.flat_map(|p| p.anchors().map(|(_, _, a)| a.p).collect::<Vec<_>>())
|
||||
.filter(|p| p.x >= from && p.x <= to)
|
||||
.map(|p| p.y.abs())
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
assert!(widest(15.0, 30.0) > 4.0 * widest(95.0, 100.0), "broad start, fine tip");
|
||||
}
|
||||
|
||||
/// Ink and Wash brushes are added and applied in one step, undone and redone, not added twice,
|
||||
/// and kept with the brushed stroke by a saved and reopened document.
|
||||
#[test]
|
||||
fn ink_and_wash_brushes_add_apply_undo_and_survive_a_saved_document() {
|
||||
let mut s = session();
|
||||
let inks = inks(&s);
|
||||
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap();
|
||||
let (defaults, before) = (brushes(&s), undo_len(&s));
|
||||
let names = ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"];
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash", "name": "Soft Wash", "apply": true, "ids": [path]}));
|
||||
assert_eq!((strs(&r["added"]), r["applied"].clone(), undo_len(&s)), (vec!["Soft Wash"], json!([path]), before + 1));
|
||||
let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone());
|
||||
assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((brushes(&s), stroke_brush(&s)), (defaults.clone(), None));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash"));
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"}));
|
||||
assert_eq!(
|
||||
(strs(&r["names"]), strs(&r["existing"]), strs(&r["added"])),
|
||||
(names.to_vec(), vec!["Soft Wash"], vec!["Tapered Ink", "Brush Pen", "Pooling Wash"])
|
||||
);
|
||||
let before = undo_len(&s);
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"}));
|
||||
assert_eq!((strs(&r["existing"]), undo_len(&s)), (names.to_vec(), before));
|
||||
assert_eq!(brushes(&s).len(), defaults.len() + 4);
|
||||
for n in names {
|
||||
assert_eq!(&brush(&s, n), inks.brush(n).unwrap(), "{n}");
|
||||
}
|
||||
|
||||
// Saved and opened again, the document keeps the definitions and the brushed stroke.
|
||||
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
|
||||
for n in names {
|
||||
assert_eq!(vectorcraft_brush::find(&reopened, n).as_ref(), inks.brush(n), "{n}");
|
||||
}
|
||||
let n = reopened.node(NodeId(path)).unwrap();
|
||||
assert_eq!(n.appearance.stroke().and_then(|st| st.brush.clone()).as_deref(), Some("Soft Wash"));
|
||||
assert_eq!(
|
||||
vectorcraft_brush::node_pieces(&reopened, n).map(|p| p.len()),
|
||||
vectorcraft_brush::node_pieces(doc(&s), doc(&s).node(NodeId(path)).unwrap()).map(|p| p.len())
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether `p` lies inside the polygon through `pts` (even-odd ray cast).
|
||||
fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool {
|
||||
let mut odd = false;
|
||||
|
||||
@@ -214,26 +214,72 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() {
|
||||
let mine = format!("loaded/{}", text(&inside));
|
||||
let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine})));
|
||||
assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}");
|
||||
assert_eq!(copied["path"], Value::Null, "{copied}");
|
||||
assert_eq!(copied.get("path"), None, "{copied}");
|
||||
let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"})));
|
||||
assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}");
|
||||
for reply in [&added, &copied, &saved] {
|
||||
assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}");
|
||||
}
|
||||
// A library loaded from data under the file name keeps its id; each concealed one takes the next
|
||||
// free one, so no two rows share an id.
|
||||
// A library loaded from data under the file name keeps its id; the concealed one shown under that
|
||||
// id takes the next free one, the other keeps its id, and no two rows share an id.
|
||||
let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string();
|
||||
run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"}));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}");
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}");
|
||||
// Copied into the user library folder, it is still found by that id, which no other library takes.
|
||||
confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"})));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}");
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}");
|
||||
// The person sees the folder.
|
||||
let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"}));
|
||||
assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches"))));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl`
|
||||
/// is shown under its whole file name. Another platform's absolute path is still named by the part
|
||||
/// after its last `\`.
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn a_concealed_library_keeps_a_backslash_in_its_file_name() {
|
||||
let base = folder("backslash-name");
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
|
||||
let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a")));
|
||||
let foreign = r"loaded/C:\Users\someone\other.gpl".to_string();
|
||||
let s = Session::new();
|
||||
let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
hidden.sort();
|
||||
let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())];
|
||||
want.sort();
|
||||
assert_eq!(hidden, want);
|
||||
}
|
||||
|
||||
/// A concealed library keeps the id a client was shown when another library with the same file name,
|
||||
/// whose full id sorts before it, is loaded later: the id still finds the first library.
|
||||
#[test]
|
||||
fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() {
|
||||
let base = folder("keeps-id");
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
|
||||
let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches"));
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
(SWATCHES.add)(&mut s, "First");
|
||||
run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"}));
|
||||
(SWATCHES.add)(&mut s, "Second");
|
||||
run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"}));
|
||||
let shown = "loaded/Kit.vcswatches";
|
||||
let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone();
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}");
|
||||
run(&mut s, "swatch.library.load", json!({"path": text(&later)}));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}");
|
||||
let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))}));
|
||||
let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown})));
|
||||
assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -728,6 +728,56 @@ fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() {
|
||||
assert_eq!(link_path(&s, a), one);
|
||||
}
|
||||
|
||||
/// With copy, for a client whose read roots don't hold the document's folder: the reply names the
|
||||
/// copy by its file name, and a copy refused there keeps the write root and names the folder and
|
||||
/// the file by their names.
|
||||
#[test]
|
||||
fn relink_with_copy_names_the_documents_folder_by_its_name_outside_the_read_roots() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let dir = Folder::new("relink-copy-roots");
|
||||
let one = dir.file("doc/one.png");
|
||||
write(&one, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &one);
|
||||
save(&mut s, &dir.file("doc/poster.vectorcraft"));
|
||||
let x = dir.file("new/x.png");
|
||||
write(&x, &png(60, 30, BLUE));
|
||||
let (new, doc) = (Path::new(&x).parent().unwrap().to_path_buf(), Path::new(&one).parent().unwrap().to_path_buf());
|
||||
let hidden = doc.to_string_lossy().into_owned();
|
||||
let p = json!({"ids": [a.0], "path": x, "copy": true});
|
||||
|
||||
let writes_there = AutomationRoots::new(&[new.as_path()], &[doc.as_path()]).unwrap();
|
||||
let r = confine(writes_there.as_ref(), || s.execute("links.relink", &p)).unwrap();
|
||||
assert_eq!(r["copied"], json!([{"from": x, "to": "x.png"}]), "{r}");
|
||||
assert!(!r.to_string().contains(&hidden), "{r}");
|
||||
std::fs::remove_file(dir.file("doc/x.png")).unwrap();
|
||||
|
||||
let elsewhere = AutomationRoots::new(&[new.as_path()], &[new.as_path()]).unwrap();
|
||||
let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&new).unwrap()).display().to_string();
|
||||
let e = confine(elsewhere.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string();
|
||||
assert_eq!(e, format!("doc: automation path rejected: outside the write root {root}: x.png"));
|
||||
assert!(!Path::new(&dir.file("doc/x.png")).exists());
|
||||
}
|
||||
|
||||
/// With copy, for a document whose path has no folder: outside the read roots the error does not
|
||||
/// name the path, and without roots it names it whole.
|
||||
#[test]
|
||||
fn relink_with_copy_names_a_document_path_without_a_folder_as_the_request_may_see_it() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let dir = Folder::new("relink-copy-no-folder");
|
||||
let mut s = session();
|
||||
s.doc_mut().unwrap().path = Some("/".into());
|
||||
let p = json!({"path": dir.file("x.png"), "copy": true});
|
||||
let roots = AutomationRoots::new(&[dir.0.as_path()], &[dir.0.as_path()]).unwrap();
|
||||
let e = confine(roots.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string();
|
||||
assert_eq!(e, "invalid parameters for `links.relink`: a file or folder outside the read roots has no folder");
|
||||
let whole = s.execute("links.relink", &p).unwrap_err().to_string();
|
||||
let doc = crate::cmd::fileio::absolute_path("/");
|
||||
assert_eq!(whole, format!("invalid parameters for `links.relink`: {doc} has no folder"));
|
||||
}
|
||||
|
||||
/// A scope or paths that doesn't fit is refused, and nothing is relinked.
|
||||
#[test]
|
||||
fn relink_refuses_scopes_and_paths_that_dont_fit() {
|
||||
|
||||
@@ -1179,6 +1179,31 @@ fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() {
|
||||
assert_eq!(offered(&mut b).len(), 2);
|
||||
}
|
||||
|
||||
/// An entry `<area>/keeps-<left area>` in a crashed app's area keeps nothing while restore or
|
||||
/// discard holds that area: both crashed apps' copies are restored, and discarded.
|
||||
#[test]
|
||||
fn a_keeps_entry_in_a_crashed_apps_area_held_by_the_same_command_keeps_nothing() {
|
||||
let crashed = |store: &Arc<MemoryStore>| {
|
||||
let mut s = session_in(store);
|
||||
s.execute("file.recovery.save", &json!({})).unwrap();
|
||||
s.recovery.own_area().unwrap().to_string()
|
||||
};
|
||||
for cmd in ["file.recovery.restore", "file.recovery.discard"] {
|
||||
let store = Arc::new(MemoryStore::default());
|
||||
let a = crashed(&store);
|
||||
let c = crashed(&store);
|
||||
assert_ne!(a, c);
|
||||
// The area that sorts first, which the command holds first, keeps the other one's copies.
|
||||
let (first, second) = if a < c { (a, c) } else { (c, a) };
|
||||
store.write(&format!("{first}/keeps-{second}"), b"").unwrap();
|
||||
let mut b = relaunch(&store);
|
||||
assert_eq!(offered(&mut b).len(), 2);
|
||||
let r = b.execute(cmd, &json!({})).unwrap();
|
||||
let done = if cmd == "file.recovery.restore" { restored(r) } else { r["discarded"].as_array().unwrap().len() };
|
||||
assert_eq!(done, 2, "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry `<area>/keeps-<left area>` in a folder whose lock file can't be opened keeps nothing.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,55 @@ fn a_confined_save_leaves_out_paths_outside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A save without a path, and Revert, refused because the document's own file is outside a confined
|
||||
/// client's roots: the error keeps the root and the reason and names the file by its file name, also
|
||||
/// for the files a save writes beside it. A path the client gives stays whole in the error.
|
||||
#[test]
|
||||
fn pathless_save_rejection_hides_document_folder() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let d = dir("roots-error");
|
||||
let (inside, outside) = (d.join("in"), d.join("kept-out"));
|
||||
for f in [&inside, &outside] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
rect(&mut s);
|
||||
let own = path(&outside, "doc.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": own})).unwrap();
|
||||
rect(&mut s);
|
||||
let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string();
|
||||
let hidden = outside.to_string_lossy().into_owned();
|
||||
let mut refused = |id: &str, p: Value| confine(roots.as_ref(), || s.execute(id, &p)).unwrap_err().to_string();
|
||||
|
||||
let e = refused("document.save", json!({}));
|
||||
assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft"));
|
||||
let e = refused("document.save", json!({"separateArtboards": true}));
|
||||
assert!(e.starts_with(&format!("automation path rejected: outside the write root {root}: ")) && !e.contains(&hidden), "{e}");
|
||||
let e = refused("file.revert", json!({}));
|
||||
assert_eq!(e, format!("automation path rejected: outside the read root {root}: doc.vectorcraft"));
|
||||
// Given by the client: whole.
|
||||
let e = refused("document.save", json!({"path": own}));
|
||||
assert_eq!(e, format!("automation path rejected: outside the write root {root}: {own}"));
|
||||
// Without read roots, a write refusal still names the file by its file name.
|
||||
let write_only = AutomationRoots::new(&[], &[inside.as_path()]).unwrap();
|
||||
let e = confine(write_only.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string();
|
||||
assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft"));
|
||||
assert!(s.doc().unwrap().is_dirty(), "nothing was saved");
|
||||
// Read with losses from its own file: the refusal to write over it names the file by its file name.
|
||||
let st = s.doc_mut().unwrap();
|
||||
st.imported_from = Some(own.clone());
|
||||
st.import_losses = vec!["hidden text".into()];
|
||||
let e = confine(roots.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string();
|
||||
assert!(e.starts_with("invalid parameters for `document.save`: doc.vectorcraft is the file this document was read from"), "{e}");
|
||||
assert!(!e.contains(&hidden), "{e}");
|
||||
let e = confine(roots.as_ref(), || s.execute("document.save", &json!({"path": own}))).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("{own} is the file this document was read from")), "{e}");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svg_path_writes_svg_and_save_writes_it_back() {
|
||||
let d = dir("svg");
|
||||
|
||||
@@ -311,7 +311,9 @@ fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() {
|
||||
s.attach_scripts_dir(root.clone());
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&work], &[]).unwrap().unwrap();
|
||||
let e = crate::file_access::confine(Some(&roots), || s.execute("script.listInstalled", &json!({}))).unwrap_err();
|
||||
assert!(e.to_string().contains("outside the read root"), "{e}");
|
||||
// The Scripts folder is the app's: the error keeps the root and names the folder by its name.
|
||||
let shown_root = crate::cmd::findfiles::plain(std::fs::canonicalize(&work).unwrap()).display().to_string();
|
||||
assert_eq!(e.to_string(), format!("automation path rejected: outside the read root {shown_root}: Scripts"));
|
||||
let e = crate::file_access::confine(Some(&roots), || s.installed_script(&script.to_string_lossy())).unwrap_err();
|
||||
assert!(e.to_string().contains("outside the read root"), "{e}");
|
||||
// A read root that holds the folder lets both through.
|
||||
|
||||
@@ -87,6 +87,50 @@ fn set_range_style_splits_runs() {
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err());
|
||||
}
|
||||
|
||||
/// `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle` refuse a size
|
||||
/// outside 0.1 to 1296 pt with an error naming the range, and change nothing; both ends are taken.
|
||||
#[test]
|
||||
fn a_type_size_outside_its_range_is_refused() {
|
||||
let mut s = session();
|
||||
let message = "`size` must be from 0.1 to 1296";
|
||||
let state = |s: &Session| (s.doc().unwrap().doc.node_count(), s.doc().unwrap().history.undo.len());
|
||||
let before = state(&s);
|
||||
for size in [2835.0, 1296.5, 0.05, 0.0, -1.0] {
|
||||
let e = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": size})).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{size}: {e}");
|
||||
}
|
||||
assert_eq!(state(&s), before, "no type is made");
|
||||
let big = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": 1296})).unwrap();
|
||||
let big = NodeId(big["id"].as_u64().unwrap());
|
||||
assert_eq!(obj(&s, big).runs[0].style.size, 1296.0);
|
||||
let small = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "small", "size": 0.1})).unwrap();
|
||||
assert_eq!(obj(&s, NodeId(small["id"].as_u64().unwrap())).runs[0].style.size, 0.1);
|
||||
let path = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone();
|
||||
let before = state(&s);
|
||||
let e = s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 2835})).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{e}");
|
||||
assert_eq!(state(&s), before);
|
||||
s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 1296})).unwrap();
|
||||
let id = text(&mut s, "Hello");
|
||||
let before = state(&s);
|
||||
for (cmd, params) in [
|
||||
("text.setStyle", json!({"ids": [id.0], "size": 2835})),
|
||||
("text.setStyle", json!({"ids": [id.0], "size": "1300 pt"})),
|
||||
("text.setStyle", json!({"ids": [id.0], "size": 0.05})),
|
||||
("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 2835})),
|
||||
("text.setRangeStyle", json!({"id": id.0, "size": 0})),
|
||||
] {
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{cmd} {params}: {e}");
|
||||
}
|
||||
assert_eq!(state(&s), before, "nothing changes");
|
||||
assert_eq!(obj(&s, id).runs[0].style.size, 20.0);
|
||||
s.execute("text.setStyle", &json!({"ids": [id.0], "size": 1296})).unwrap();
|
||||
assert_eq!(obj(&s, id).runs[0].style.size, 1296.0);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "size": 0.1})).unwrap();
|
||||
assert_eq!(obj(&s, id).runs[0].style.size, 0.1);
|
||||
}
|
||||
|
||||
/// `featureChanges` turns features on and off on top of each run's own and keeps the rest, in
|
||||
/// `text.setStyle` and `text.setRangeStyle`, in one undo step; an unknown tag is an error.
|
||||
#[test]
|
||||
|
||||
@@ -2958,6 +2958,13 @@ fn the_hpgl_sample_opens() {
|
||||
assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings);
|
||||
}
|
||||
|
||||
/// A wedge drawn with the adaptive line type `LT-1` and a near-zero radius (a case the fuzz test
|
||||
/// found) opens and places without a panic.
|
||||
#[test]
|
||||
fn an_adaptive_line_type_wedge_of_almost_no_radius_opens() {
|
||||
survive_metafile("HP-GL tiny adaptive wedge", "x.plt", b"IN;SP1;LT-1;EW0.000001,0,0;").unwrap();
|
||||
}
|
||||
|
||||
/// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers.
|
||||
fn arb_hpgl_instruction() -> impl Strategy<Value = String> {
|
||||
let op = prop::sample::select(vec![
|
||||
|
||||
@@ -294,6 +294,134 @@ fn writing_again_gives_the_same_bytes() {
|
||||
assert!(first.warnings.is_empty(), "{:?}", first.warnings);
|
||||
}
|
||||
|
||||
/// The CMYK equivalent written beside RGB or Lab values is the one of the values as written (to four
|
||||
/// decimals, Lab clamped to the range a reader reads), so the reopened file is written with the same
|
||||
/// numbers: a new document's swatches, and colors of many decimals in fills, gradient stops, global
|
||||
/// process swatches (RGB and Lab), an RGB spot swatch (in a fill and in a stop), Lab spot swatches
|
||||
/// and a Lab spot swatch out of range.
|
||||
#[test]
|
||||
fn cmyk_equivalents_are_written_again_unchanged() {
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract();
|
||||
let rgb = |i: u16| Color::rgb(v(i, 0.137_911_3), v(i, 0.271_828_2), v(i, 0.314_159_3));
|
||||
let lab = |i: u16| Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0);
|
||||
let glaze = rgb(102);
|
||||
let swatch = |name: String, c: Color, spot: bool| Swatch { name, paint: solid(c), global: true, spot };
|
||||
let mut named: Vec<(String, Color, bool)> =
|
||||
vec![("Glaze".into(), glaze, true), ("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65), true)];
|
||||
for i in 0..8u16 {
|
||||
named.push((format!("Leaf {i}"), rgb(i + 110), false));
|
||||
named.push((format!("Fern {i}"), lab(i + 120), false));
|
||||
named.push((format!("Moss {i}"), lab(i + 130), true));
|
||||
}
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
|
||||
swatches.extend(named.iter().map(|(name, c, spot)| swatch(name.clone(), *c, *spot)));
|
||||
doc.swatches = swatches.into();
|
||||
let layer = doc.layers[0].id;
|
||||
for i in 0..24u16 {
|
||||
let x = f64::from(i % 8) * 30.0 + 10.0;
|
||||
let y = f64::from(i / 8) * 30.0 + 10.0;
|
||||
let n = square(&mut doc, Rect::new(x, y, x + 20.0, y + 20.0), rgb(i));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let mut stops: Vec<GradientStop> = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect();
|
||||
let out = named[1].1;
|
||||
stops.push(GradientStop { swatch: Some("Out".into()), tint: 0.5, ..GradientStop::new(0.9, out.tinted(0.5)) });
|
||||
stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) });
|
||||
let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)];
|
||||
fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 })));
|
||||
for (i, fill) in fills.into_iter().enumerate() {
|
||||
let x = (i % 9) as f64 * 30.0 + 10.0;
|
||||
let y = (i / 9) as f64 * 30.0 + 110.0;
|
||||
let n = painted(&mut doc, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let first = program(&doc);
|
||||
let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n");
|
||||
same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// With a working RGB space other than sRGB, an RGB fill, an RGB gradient stop and an RGB spot
|
||||
/// color in a stop are written with their working RGB values, the values the reader reads back, so
|
||||
/// the reopened file is written with the same RGB values and CMYK equivalents.
|
||||
#[test]
|
||||
fn rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space() {
|
||||
const CHILD: &str = "VECTORCRAFT_TEST_AI_EDITING_WIDE_GAMUT";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
// Color settings are process-wide: exercise this profile in a separate test process.
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "ai_editing::tests::rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space", "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
|
||||
"isolated color profile test failed:\n{stdout}\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::cms;
|
||||
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
|
||||
assert!(!cms::active().rgb_is_srgb());
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let glaze = Color::rgb(0.812_345_6, 0.234_567_8, 0.456_789_1);
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
|
||||
swatches.push(Swatch { name: "Glaze".into(), paint: solid(glaze), global: true, spot: true });
|
||||
doc.swatches = swatches.into();
|
||||
let layer = doc.layers[0].id;
|
||||
let n = square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.4, 0.2));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let stops = vec![
|
||||
GradientStop::new(0.0, Color::rgb(0.1, 0.7, 0.3)),
|
||||
GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) },
|
||||
];
|
||||
let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None);
|
||||
let n = painted(&mut doc, shapes::rectangle(Rect::new(40.0, 10.0, 60.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let first = program(&doc);
|
||||
assert!(first.contains("0.8 0.4 0.2 Xa"), "the fill's working RGB values are written");
|
||||
let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n");
|
||||
same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// A gradient stop of a Lab spot color writes the RGB values of the Lab values as written (to four
|
||||
/// decimals, clamped to the range a reader reads), the same Lab values its CMYK equivalent and the
|
||||
/// palette's definition come from: for Lab values of many decimals and for Lab values out of range.
|
||||
#[test]
|
||||
fn a_lab_spot_stop_writes_rgb_values_of_its_lab_values_as_written() {
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract();
|
||||
let mut spots: Vec<(String, Color)> = vec![("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65))];
|
||||
spots.push(("Rim".into(), Color::lab(55.555_55, 131.234_56, -12.345_67)));
|
||||
for i in 0..8u16 {
|
||||
spots
|
||||
.push((format!("Moss {i}"), Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0)));
|
||||
}
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
|
||||
swatches.extend(spots.iter().map(|(name, c)| Swatch { name: name.clone(), paint: solid(*c), global: true, spot: true }));
|
||||
doc.swatches = swatches.into();
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, (name, c)) in spots.iter().enumerate() {
|
||||
let stops = vec![
|
||||
GradientStop::new(0.0, Color::rgb(0.1, 0.2, 0.3)),
|
||||
GradientStop { swatch: Some(name.clone()), tint: 0.5, ..GradientStop::new(1.0, c.tinted(0.5)) },
|
||||
];
|
||||
let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None);
|
||||
let x = i as f64 * 25.0 + 10.0;
|
||||
let n = painted(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let program = program(&doc);
|
||||
for (name, c) in &spots {
|
||||
let mut want = String::new();
|
||||
crate::ps::push_nums(&mut want, &crate::ai_ops::as_written(*c).to_rgb().map(|v| f64::from(crate::ai_ops::unit(v))));
|
||||
want.push_str(&format!("({name}) 0.5 1 4 "));
|
||||
assert!(program.lines().any(|l| l.contains(&want)), "no stop of {name} writes {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_data_is_zstandard_compressed_with_the_header_as_metadata() {
|
||||
let doc = sample();
|
||||
@@ -5872,25 +6000,6 @@ fn outlines(doc: &Document) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Fails with the lines where `got` and `want` differ in more than their numbers' last written
|
||||
/// decimal (a color read back from four decimals gives its other model's values again from them).
|
||||
fn near_lines(got: &str, want: &str) {
|
||||
let near = |a: &str, b: &str| {
|
||||
let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect());
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::<f64>(), y.trim_start_matches("%_").parse::<f64>()) {
|
||||
(Ok(x), Ok(y)) => (x - y).abs() <= 2e-3,
|
||||
_ => x == y,
|
||||
})
|
||||
};
|
||||
let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect());
|
||||
let differ: Vec<String> = (0..g.len().max(w.len()))
|
||||
.filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b)))
|
||||
.map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i)))
|
||||
.collect();
|
||||
assert!(differ.is_empty(), "{}", differ.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_with_live_effects_and_graphic_styles_are_read_back() {
|
||||
let doc = styles_document();
|
||||
@@ -6212,25 +6321,7 @@ fn art_styles_write_the_same_bytes_again() {
|
||||
assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0);
|
||||
// What is read back is written as it was.
|
||||
let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program;
|
||||
near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program)));
|
||||
}
|
||||
|
||||
/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of
|
||||
/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and
|
||||
/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this
|
||||
/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can
|
||||
/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float
|
||||
/// bits, as it does between the first save and the save of the reopened file, and the platform's
|
||||
/// float math decides which. The RGB values stay compared.
|
||||
fn rgb_without_cmyk(program: &str) -> String {
|
||||
let blank = |line: &str| {
|
||||
let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r));
|
||||
let t: Vec<&str> = rest.split(' ').collect();
|
||||
let color = t.len() == 8 && matches!(t[7], "Xa" | "XA");
|
||||
let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs");
|
||||
if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() }
|
||||
};
|
||||
program.lines().map(blank).collect::<Vec<_>>().join("\n")
|
||||
same_lines(&shown(&again).lines().map(String::from).collect::<Vec<_>>(), &shown(&first.program).lines().map(String::from).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// `n` on the first layer of `doc`.
|
||||
|
||||
@@ -1357,8 +1357,9 @@ impl<'a> Writer<'a> {
|
||||
self.plain_color(*color, stroke);
|
||||
}
|
||||
|
||||
/// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and
|
||||
/// Lab as RGB), with its CMYK equivalent.
|
||||
/// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB in the
|
||||
/// working RGB space, and Lab as display RGB), with its CMYK equivalent (an RGB color's from its
|
||||
/// values as written, a Lab color's from its Lab values).
|
||||
fn plain_color(&mut self, color: Color, stroke: bool) {
|
||||
let s = &mut self.body;
|
||||
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
|
||||
@@ -1373,19 +1374,22 @@ impl<'a> Writer<'a> {
|
||||
let _ = writeln!(s, "{}", op("k", "K"));
|
||||
}
|
||||
Color::Rgb { .. } | Color::Lab { .. } => {
|
||||
let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit);
|
||||
let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color };
|
||||
let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v))));
|
||||
ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from));
|
||||
let _ = writeln!(s, "{}", op("Xa", "XA"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to
|
||||
/// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full
|
||||
/// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before
|
||||
/// `Xx` (spot) or `Xk` (global process).
|
||||
/// `s`: its CMYK equivalent (from its RGB or Lab values as written), its RGB or Lab values
|
||||
/// (clamped as [`ai_ops::as_written`] clamps them), the name, how much lighter than the full
|
||||
/// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before `Xx`
|
||||
/// (spot) or `Xk` (global process).
|
||||
fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) {
|
||||
let full = ai_ops::as_written(full);
|
||||
let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let model = match full {
|
||||
@@ -1481,7 +1485,8 @@ impl<'a> Writer<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// The spot color swatches, with their full colors' CMYK equivalents, for the header.
|
||||
/// The spot color swatches, with their full colors' CMYK equivalents (as the palette writes
|
||||
/// them), for the header.
|
||||
pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> {
|
||||
let intent = self.cms.settings().intent;
|
||||
self.doc
|
||||
@@ -1489,7 +1494,7 @@ impl<'a> Writer<'a> {
|
||||
.filter(|sw| sw.spot)
|
||||
.filter_map(|sw| match sw.paint {
|
||||
Paint::Solid { color, swatch: None, .. } => {
|
||||
Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit)))
|
||||
Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(ai_ops::as_written(color), true), intent).map(unit)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
|
||||
|
||||
use super::{MAX_NEST, Writer, Xy};
|
||||
use crate::ai_ops::{average, unit};
|
||||
use crate::ai_ops::{self, average, unit};
|
||||
use crate::ps;
|
||||
|
||||
pub(in crate::ai_editing) const GRADIENTS: &str =
|
||||
@@ -371,7 +371,11 @@ impl Writer<'_> {
|
||||
/// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB)
|
||||
/// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent,
|
||||
/// then its RGB values when it isn't CMYK, its name and how much lighter than the full color,
|
||||
/// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB).
|
||||
/// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). RGB values are an RGB color's own (in the
|
||||
/// working RGB space) and a Lab color's display RGB ([`ai_ops::rgb_values`]). An RGB color's CMYK
|
||||
/// equivalent is converted from its values as written ([`ai_ops::as_written`]), a Lab color's
|
||||
/// from its Lab values, and a spot color's CMYK equivalent and RGB values both come from its full
|
||||
/// color as the palette writes it.
|
||||
fn stop_color(&mut self, s: &mut String, stop: &GradientStop) {
|
||||
let doc = self.doc;
|
||||
let intent = self.cms.settings().intent;
|
||||
@@ -380,11 +384,12 @@ impl Writer<'_> {
|
||||
&& let Paint::Solid { color: full, swatch: None, .. } = sw.paint
|
||||
{
|
||||
if sw.spot {
|
||||
let full = ai_ops::as_written(full);
|
||||
let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let rgb = !matches!(full, Color::Cmyk { .. });
|
||||
if rgb {
|
||||
ps::push_nums(s, &full.to_rgb().map(|v| f64::from(unit(v))));
|
||||
ps::push_nums(s, &ai_ops::rgb_values(full).map(f64::from));
|
||||
}
|
||||
s.push_str(&ps::bytes(name.as_bytes()));
|
||||
s.push(' ');
|
||||
@@ -406,9 +411,10 @@ impl Writer<'_> {
|
||||
s.push_str("1 ");
|
||||
}
|
||||
color @ (Color::Rgb { .. } | Color::Lab { .. }) => {
|
||||
let cmyk = self.cms.to_cmyk(&color, intent).map(unit);
|
||||
let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color };
|
||||
let cmyk = self.cms.to_cmyk(&source, intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v))));
|
||||
ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from));
|
||||
s.push_str("2 ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,27 @@ pub(crate) fn unit(v: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// `color` as a reader reads it back from the values written for it ([`ps::written`]): RGB values
|
||||
/// clamped to 0..1, L to 0..100 and a and b to -128..127, each to four decimals; other colors
|
||||
/// unchanged.
|
||||
pub(crate) fn as_written(color: Color) -> Color {
|
||||
let w = |v: f32| ps::written(f64::from(v)) as f32;
|
||||
match color {
|
||||
Color::Rgb { r, g, b } => Color::rgb(w(unit(r)), w(unit(g)), w(unit(b))),
|
||||
Color::Lab { l, a, b } => Color::lab(w(l.clamp(0.0, 100.0)), w(a.clamp(-128.0, 127.0)), w(b.clamp(-128.0, 127.0))),
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGB values written for `color`, clamped to 0..1: an RGB color's own values (in the working
|
||||
/// RGB space, as a reader reads them back), any other color's display RGB.
|
||||
pub(crate) fn rgb_values(color: Color) -> [f32; 3] {
|
||||
match color {
|
||||
Color::Rgb { r, g, b } => [r, g, b].map(unit),
|
||||
c => c.to_rgb().map(unit),
|
||||
}
|
||||
}
|
||||
|
||||
/// The average colour of a gradient's stops (in RGB).
|
||||
pub(crate) fn average(g: &Gradient) -> Color {
|
||||
let n = g.stops.len().max(1) as f32;
|
||||
|
||||
@@ -1027,8 +1027,7 @@ fn affine_corners_are_a_parallelogram_however_far_they_reach() {
|
||||
|
||||
#[test]
|
||||
fn roughen_keeps_the_drawn_look() {
|
||||
// Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the
|
||||
// path's segments.
|
||||
// Its outline is random and the file holds no seed.
|
||||
assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None);
|
||||
assert_eq!(read_effect("Adobe Roughen", ""), None);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ pub(crate) struct Interp<'a> {
|
||||
/// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in
|
||||
/// a font of its own (see `text`).
|
||||
pub kshow_font: Option<DictRef>,
|
||||
/// The `cshow` procedure running over type in a CIDFont only named, with the font it sees as
|
||||
/// current (see `text`).
|
||||
pub cshow: Option<super::text::CShowFont>,
|
||||
pub g: GState,
|
||||
pub saved: Vec<GState>,
|
||||
pub out: Out,
|
||||
@@ -240,6 +243,7 @@ impl<'a> Interp<'a> {
|
||||
cmap_font: 0,
|
||||
predefined_cmaps: vec![],
|
||||
kshow_font: None,
|
||||
cshow: None,
|
||||
type42_counted: HashSet::new(),
|
||||
g,
|
||||
saved: vec![],
|
||||
|
||||
@@ -191,6 +191,36 @@ fn left_out_of_comparisons(page: Page, mut restyled: Vec<Rect>, placeholders: Ve
|
||||
restyled
|
||||
}
|
||||
|
||||
/// A single PDF page capped at the format's 14,400-unit limit, without user-space scaling.
|
||||
/// The other dimension must still match; unrelated page-size mismatches remain errors.
|
||||
fn capped_pdf_page(doc: &Document, visible: &Document, board: Rect, page: Rect, unit: f64) -> bool {
|
||||
let capped = |side: f64, pdf: f64| (pdf - 14_400.0).abs() <= 0.5 && side > pdf + 0.5;
|
||||
let matching = |side: f64, pdf: f64| (side - pdf).abs() <= 0.5 || capped(side, pdf);
|
||||
unit == 1.0
|
||||
&& doc.artboards.len() == 1
|
||||
&& visible.artboards.len() == 1
|
||||
&& (capped(board.width(), page.width()) || capped(board.height(), page.height()))
|
||||
&& matching(board.width(), page.width())
|
||||
&& matching(board.height(), page.height())
|
||||
}
|
||||
/// The PDF-covered area of a trusted oversized artboard; its full artboard stays in the document.
|
||||
fn compared_page_rect(
|
||||
doc: &Document,
|
||||
visible: &Document,
|
||||
board: Rect,
|
||||
pdf: Rect,
|
||||
trusted_page: Option<Page>,
|
||||
unit: f64,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Rect {
|
||||
if matches!(trusted_page, Some(Page::Artboard)) && capped_pdf_page(doc, visible, board, pdf, unit) {
|
||||
warnings.push("the editing data's artboard is larger than the PDF page: the full artboard comes from its editing data".into());
|
||||
Rect::new(board.x0, board.y0, board.x0 + pdf.width(), board.y0 + pdf.height())
|
||||
} else {
|
||||
board
|
||||
}
|
||||
}
|
||||
|
||||
/// [`build`] from `structure`, the editing copy `data` already read.
|
||||
pub(super) fn build_from(
|
||||
structure: Structure,
|
||||
@@ -223,7 +253,7 @@ pub(super) fn build_from(
|
||||
bindings,
|
||||
bindings_skipped,
|
||||
warped_type,
|
||||
warnings,
|
||||
mut warnings,
|
||||
} = structure;
|
||||
let mut restyled = left_out_of_comparisons(page, restyled, placeholders);
|
||||
// Editing data in the units of the page's user space ([`in_units`]), or at its declared large
|
||||
@@ -271,6 +301,7 @@ pub(super) fn build_from(
|
||||
{
|
||||
(rect, cropped) = (art, true);
|
||||
}
|
||||
rect = compared_page_rect(&doc, visible, rect, page_rect, trust.then_some(page), unit, &mut warnings);
|
||||
if !fits(&rect) {
|
||||
// Both sizes in points as the fields show them, and the editing data's page to the
|
||||
// file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and
|
||||
|
||||
@@ -288,7 +288,7 @@ fn code_space(cmap: &str) -> Option<CodeSpace> {
|
||||
"83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC,
|
||||
_ if stem.ends_with("RKSJ") => RKSJ,
|
||||
"EUC" | "78-EUC" => EUC_JP,
|
||||
"" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL,
|
||||
"" | "78" | "Add" | "Ext" | "NWP" | "GBT" | "CNS2" => ROW_CELL,
|
||||
"GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC,
|
||||
"GBpc-EUC" | "GBTpc-EUC" => GB_PC,
|
||||
"GBK-EUC" | "GBKp-EUC" => GBK,
|
||||
@@ -327,8 +327,9 @@ pub(super) fn known(cmap: &str) -> bool {
|
||||
|
||||
/// The codes of string `bytes` in predefined CMap `cmap` when its mappings can't be read, each
|
||||
/// standing for no character: as long as the code space range it falls in ([`code_space`]; a byte
|
||||
/// that starts no code is a code of its own), or two bytes when the code space isn't listed (the
|
||||
/// row-and-cell codes of `GBT-H` and `CNS2-H`). A 0x0D or 0x09 byte is always a code of its own.
|
||||
/// that starts no code is a code of its own, as in the row-and-cell code space of `GBT-H` and
|
||||
/// `CNS2-H`), or two bytes when the code space isn't listed. A 0x0D or 0x09 byte is always a code
|
||||
/// of its own.
|
||||
pub(super) fn unread_codes(cmap: &str, bytes: &[u8]) -> Decoded {
|
||||
let space = code_space(cmap);
|
||||
let mut out = Decoded::default();
|
||||
|
||||
@@ -487,21 +487,29 @@ fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() {
|
||||
measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt"));
|
||||
measured(&format!("{set} 10 20 moveto <93fa967b> [10 10] yshow currentpoint 40 sub abs 0.01 lt exch 10 sub abs 0.01 lt and"));
|
||||
measured(&format!("{set} 10 20 moveto <93fa967b> [30 10 30 10] xyshow currentpoint 40 sub abs 0.01 lt exch 70 sub abs 0.01 lt and"));
|
||||
// `kshow` runs its procedure between each two codes: it moves the point 100 more here, and a
|
||||
// font it sets stays current.
|
||||
// `kshow` runs its procedure between each two codes: it moves the point 100 more here. The
|
||||
// current font is restored when the procedure completes (PLRM 3rd ed., `kshow`), so a font it
|
||||
// sets doesn't stay current, here or for a font found by name.
|
||||
measured(&format!(
|
||||
"{set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop 100 0 rmoveto}} <93fa967b> kshow currentpoint \
|
||||
3 -1 roll sub abs 0.01 lt 3 1 roll sub -100 sub abs 0.01 lt and"
|
||||
));
|
||||
measured(&format!(
|
||||
"{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Helvetica eq"
|
||||
"{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq"
|
||||
));
|
||||
// The procedure sees the font the file set as current, and the next one the font an earlier
|
||||
// one set.
|
||||
measured(
|
||||
"/Helvetica findfont 20 scalefont setfont 10 20 moveto {pop pop /Courier findfont 10 scalefont setfont} (AB) kshow currentfont /FontName get /Helvetica eq",
|
||||
);
|
||||
// Each procedure sees the font the file set as current, also after an earlier one set another.
|
||||
measured(&format!("{set} 10 20 moveto {{pop pop currentfont /FontName get /seen exch def}} <93fa967b> kshow seen /Ryumin-Light-90ms-RKSJ-H eq"));
|
||||
measured(&format!(
|
||||
"{set} 10 20 moveto /n 0 def {{pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont}} {{currentfont /FontName get /seen exch def}} ifelse}} \
|
||||
<93fa967b41> kshow seen /Helvetica eq"
|
||||
<93fa967b41> kshow seen /Ryumin-Light-90ms-RKSJ-H eq"
|
||||
));
|
||||
// A `kshow` the procedure runs in a font it set sees that font.
|
||||
measured(&format!(
|
||||
"{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont {{pop pop currentfont /FontName get /seen exch def}} (AB) kshow}} \
|
||||
<93fa967b> kshow seen /Helvetica eq"
|
||||
));
|
||||
// The procedure gets the two codes' values (left on the stack here).
|
||||
measured(&format!(
|
||||
@@ -575,8 +583,9 @@ fn named_cid_type_is_set_alike_by_every_show_operator() {
|
||||
|
||||
/// `charpath`, `cshow` and `glyphshow` in a CID-keyed font over a predefined CMap, its CIDFont only
|
||||
/// named: `charpath` adds the outlines of the characters the codes stand for and moves the point
|
||||
/// as `show` does; `cshow`'s procedure gets each code (two bytes for a two-byte code) with its
|
||||
/// width; `glyphshow` sets the named character itself.
|
||||
/// as `show` does; `cshow`'s procedure gets each code's last byte with its width and runs with the
|
||||
/// CIDFont current, a show in it setting the code's character; `glyphshow` sets the named
|
||||
/// character itself.
|
||||
#[test]
|
||||
fn named_cid_type_in_charpath_cshow_and_glyphshow() {
|
||||
let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont";
|
||||
@@ -595,9 +604,35 @@ fn named_cid_type_in_charpath_cshow_and_glyphshow() {
|
||||
top("fd41"),
|
||||
top("41")
|
||||
));
|
||||
// The procedure leaves each code: 日 (0x93FA), then A.
|
||||
check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#93fa eq exch 1 get 65 eq and and"));
|
||||
// The procedure leaves each code's last byte (PLRM 3rd ed., `cshow`): 日 (0x93FA), then A.
|
||||
check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#fa eq exch 1 get 65 eq and and"));
|
||||
check(&format!("{set} 0 {{pop exch pop add}} <93fa967b41> cshow <93fa967b41> stringwidth pop sub abs 0.01 lt"));
|
||||
// While the procedure runs, the current font is the CIDFont and `rootfont` gives the composite
|
||||
// font (PLRM 3rd ed., `cshow` and `rootfont`); the current font is restored after each run,
|
||||
// also when the procedure sets another.
|
||||
check(&format!(
|
||||
"{set} {{pop pop pop currentfont dup /FontType get /t exch def /CIDFontName get /seen exch def}} <93fa> cshow \
|
||||
seen /Ryumin-Light eq t 9 eq and"
|
||||
));
|
||||
check(&format!("{set} {{pop pop pop rootfont /FontName get /seen exch def}} <93fa> cshow seen /Ryumin-Light-90ms-RKSJ-H eq"));
|
||||
check(&format!(
|
||||
"{set} /n 0 def {{pop pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont rootfont /FontName get /r exch def}} \
|
||||
{{currentfont /CIDFontName get /seen exch def}} ifelse}} <93fa41> cshow seen /Ryumin-Light eq r /Helvetica eq and \
|
||||
currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq and"
|
||||
));
|
||||
// A show in the CIDFont shows the character of the code the procedure was given, the string
|
||||
// holding its last byte, and moves the point as far as `show` does. Any other string, an
|
||||
// empty one included, is a `rangecheck` error.
|
||||
for s in ["()", "(AB)", "<41>"] {
|
||||
check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop {s} show}} <93fa> cshow}} stopped grestore"));
|
||||
}
|
||||
check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop <fa> show}} <93fa> cshow}} stopped not grestore"));
|
||||
let r = super::tests::read(&format!("{set} 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow"));
|
||||
assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<String>(), "日本");
|
||||
check(&format!(
|
||||
"gsave nulldevice {set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow \
|
||||
currentpoint grestore 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and"
|
||||
));
|
||||
let r = super::tests::read(&format!("{set} 10 20 moveto /uni00E9 glyphshow"));
|
||||
assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["é"]);
|
||||
}
|
||||
@@ -640,6 +675,8 @@ fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() {
|
||||
"/Ryumin-Light-GBT-V findfont 20 scalefont setfont 10 100 moveto <21212121> show currentpoint 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and",
|
||||
// A tab is a code of its own: three codes.
|
||||
"/Ryumin-Light-GBT-H findfont 20 scalefont setfont <09092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and",
|
||||
// A byte that starts no row-and-cell code is a code of its own, also before a tab: three.
|
||||
"/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and",
|
||||
] {
|
||||
let r = super::tests::read(&format!("{cond} {{ 0 0 10 10 rectfill }} if"));
|
||||
assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{cond}");
|
||||
|
||||
@@ -7005,3 +7005,21 @@ fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() {
|
||||
assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Hi"], "{w:?}");
|
||||
assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}");
|
||||
}
|
||||
|
||||
/// #6865: explicitly trusting editing data keeps an artboard larger than its PDF page.
|
||||
#[test]
|
||||
fn trusted_editing_data_keeps_an_artboard_taller_than_its_pdf_page() {
|
||||
let art = native(&layer("Oversized", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 12992.726 14637.147");
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, true, 1.0);
|
||||
assert!(read, "{w:?}");
|
||||
assert_eq!(names(&d), ["Oversized"], "{w:?}");
|
||||
assert!((d.artboards[0].rect.height() - 14637.147).abs() < 0.001);
|
||||
assert_eq!(kinds(&d.layers[0]), ["path"]);
|
||||
assert!(w.iter().any(|w| w.contains("artboard is larger than the PDF page")), "{w:?}");
|
||||
let child = &d.layers[0].children().unwrap()[0];
|
||||
assert!(child.visual_bounds().unwrap().y0 > 14400.0, "art beyond the PDF page is kept");
|
||||
let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, false, 1.0);
|
||||
assert!(!read, "default import still rejects the size mismatch: {w:?}");
|
||||
let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12000.0, 14400.0), vec![], false, true, 1.0);
|
||||
assert!(!read, "the uncapped dimension must still match: {w:?}");
|
||||
}
|
||||
|
||||
@@ -186,22 +186,15 @@ impl Interp<'_> {
|
||||
// The procedure shows or places each character: it gets its code and width.
|
||||
let s = self.pop_str()?.to_vec();
|
||||
let proc = self.pop_proc()?;
|
||||
// In a CIDFont only named, a code may be several bytes: the procedure gets each
|
||||
// code's value.
|
||||
// In a CIDFont only named, a code may be several bytes: the procedure runs once for
|
||||
// each code.
|
||||
if let Some(named) = self.named_cid_type(&s)? {
|
||||
let mut chars = named.codes.text.chars();
|
||||
for &(code, n) in &named.codes.codes {
|
||||
let mut one = Decoded::default();
|
||||
one.push(code, chars.by_ref().take(n));
|
||||
let v = self.named_cid_width(&named, &one)?;
|
||||
self.push(Obj::Int(i64::from(code)))?;
|
||||
self.push_num(v.x)?;
|
||||
self.push_num(v.y)?;
|
||||
if !self.body(&proc)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
let root = self.g.font.clone();
|
||||
let before = self.cshow.take();
|
||||
let r = self.named_cshow(named, &proc, root.clone());
|
||||
self.cshow = before;
|
||||
self.g.font = root;
|
||||
return r;
|
||||
}
|
||||
for b in s {
|
||||
let v = self.width_of(&[b])?;
|
||||
@@ -252,7 +245,12 @@ impl Interp<'_> {
|
||||
SetCharWidth => self.glyph_width = Some(self.nums::<2>()?),
|
||||
SetCacheDevice2 => self.glyph_width = Some(self.nums::<10>()?[..2].try_into().unwrap_or_default()),
|
||||
RootFont => {
|
||||
let font = match self.g.font.clone() {
|
||||
// Inside a `cshow` procedure the composite font, unless the procedure set a font.
|
||||
let root = match (&self.cshow, &self.g.font) {
|
||||
(Some(c), Some(f)) if f.same(&c.selected) => Some(c.root.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let font = match root.or_else(|| self.g.font.clone()) {
|
||||
Some(f) => f,
|
||||
None => self.find_font(&Obj::name(DEFAULT_FONT))?,
|
||||
};
|
||||
@@ -791,18 +789,16 @@ impl Interp<'_> {
|
||||
self.push(Obj::Int(code.into()))?;
|
||||
self.push(Obj::Int(next.into()))?;
|
||||
// The procedure sees the font the file set, not the one type in a CIDFont only named is
|
||||
// set in; a font it sets stays current.
|
||||
let Some(outer) = self.kshow_font.take() else {
|
||||
self.call(p.clone())?;
|
||||
return Ok(self.user_point()? - pen);
|
||||
// set in, and the current font is restored when it completes (PLRM 3rd ed., `kshow`).
|
||||
// A `kshow` the procedure runs sees the font the procedure set.
|
||||
let outer = self.kshow_font.take();
|
||||
let inner = match outer.clone() {
|
||||
Some(outer) => self.g.font.replace(outer),
|
||||
None => self.g.font.clone(),
|
||||
};
|
||||
let inner = self.g.font.replace(outer.clone());
|
||||
let r = self.call(p.clone());
|
||||
// Once the procedure sets a font, it and the later procedures see that one.
|
||||
if self.g.font.as_ref().is_some_and(|f| f.same(&outer)) {
|
||||
self.g.font = inner;
|
||||
self.kshow_font = Some(outer);
|
||||
}
|
||||
self.kshow_font = outer;
|
||||
self.g.font = inner;
|
||||
r?;
|
||||
Ok(self.user_point()? - pen)
|
||||
}
|
||||
@@ -960,6 +956,15 @@ struct NamedCid {
|
||||
ems: bool,
|
||||
}
|
||||
|
||||
/// A `cshow` procedure running over type in a CIDFont only named ([`Interp::named_cshow`]): the
|
||||
/// CIDFont it sees as current, the composite font `rootfont` gives while that CIDFont is current,
|
||||
/// and the type of the code it was given.
|
||||
pub(super) struct CShowFont {
|
||||
selected: DictRef,
|
||||
root: DictRef,
|
||||
code: NamedCid,
|
||||
}
|
||||
|
||||
/// What drawing a glyph does with it.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Draw {
|
||||
@@ -1092,7 +1097,17 @@ impl Interp<'_> {
|
||||
/// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical
|
||||
/// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't
|
||||
/// read is left out, with a warning, each of its codes moving the point one em.
|
||||
/// Inside a `cshow` procedure over such type, with the CIDFont current, the string must be the
|
||||
/// one byte the procedure was given, else a `rangecheck` error, and it stands for the code
|
||||
/// the procedure was given (PLRM 3rd ed., `cshow`: the glyph of the selected CID).
|
||||
fn named_cid_type(&mut self, s: &[u8]) -> Res<Option<NamedCid>> {
|
||||
if let Some(named) = self.cshow_code(true) {
|
||||
let given = named.codes.codes.first().map(|&(code, _)| code & 0xff);
|
||||
if s.len() != 1 || given != s.first().map(|&b| u32::from(b)) {
|
||||
return ps_err("rangecheck", "a string shown in cshow");
|
||||
}
|
||||
return Ok(Some(named));
|
||||
}
|
||||
let Some(cmap) = self.named_cid_parts().map(|(cmap, ..)| cmap) else { return Ok(None) };
|
||||
// Each code is looked up in the CMap's mappings.
|
||||
self.spend(s.len() as u64)?;
|
||||
@@ -1120,6 +1135,9 @@ impl Interp<'_> {
|
||||
/// The font [`Self::named_cid_type`] sets type in, with no codes yet, when the current font is
|
||||
/// a CID-keyed font over a predefined CMap the file doesn't define.
|
||||
fn named_cid_font(&mut self) -> Res<Option<NamedCid>> {
|
||||
if let Some(named) = self.cshow_code(false) {
|
||||
return Ok(Some(named));
|
||||
}
|
||||
let Some((_, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) };
|
||||
// The composite font is copied under the CIDFont's name for every string: charged by
|
||||
// its size, so a huge font dictionary shown in a loop runs out of the budget.
|
||||
@@ -1130,6 +1148,23 @@ impl Interp<'_> {
|
||||
Ok(Some(NamedCid { codes: Decoded::default(), font: DictRef::new(named), vertical, ems: vertical }))
|
||||
}
|
||||
|
||||
/// The type of the code a running `cshow` procedure was given ([`Self::named_cshow`]), with
|
||||
/// that code when `code` and with none otherwise, while the CIDFont it selected is current.
|
||||
fn cshow_code(&self, code: bool) -> Option<NamedCid> {
|
||||
let c = self.cshow.as_ref()?;
|
||||
if !self.g.font.as_ref().is_some_and(|f| f.same(&c.selected)) {
|
||||
return None;
|
||||
}
|
||||
let mut codes = Decoded::default();
|
||||
if code {
|
||||
let mut chars = c.code.codes.text.chars();
|
||||
for &(value, n) in &c.code.codes.codes {
|
||||
codes.push(value, chars.by_ref().take(n));
|
||||
}
|
||||
}
|
||||
Some(NamedCid { codes, font: c.code.font.clone(), vertical: c.code.vertical, ems: c.code.ems })
|
||||
}
|
||||
|
||||
/// The CMap's name, the composite font, the CIDFont's name and the writing mode of
|
||||
/// [`Self::named_cid_type`].
|
||||
fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> {
|
||||
@@ -1191,13 +1226,41 @@ impl Interp<'_> {
|
||||
let before = std::mem::replace(&mut self.kshow_font, saved.clone());
|
||||
let r = self.show_codes(&named.codes, widths.as_deref(), spacing, named.vertical, &kern, draw);
|
||||
self.kshow_font = before;
|
||||
// A font the `kshow` procedure set stays current.
|
||||
if self.g.font.as_ref().is_none_or(|f| f.same(&named.font)) {
|
||||
self.g.font = saved;
|
||||
}
|
||||
self.g.font = saved;
|
||||
r
|
||||
}
|
||||
|
||||
/// `cshow`'s procedure `proc` run for each code of type `named` in composite font `root`, with
|
||||
/// the code's last byte and its width (PLRM 3rd ed., `cshow`). While it runs the CIDFont is the
|
||||
/// current font and `rootfont` gives `root`; a show operator in the CIDFont shows the code's
|
||||
/// character, whatever its string (see [`Self::named_cid_type`]). The current font is restored
|
||||
/// when the procedure completes.
|
||||
fn named_cshow(&mut self, named: NamedCid, proc: &Obj, root: Option<DictRef>) -> Res {
|
||||
let Some(composite) = root.clone() else { return Ok(()) };
|
||||
let dep = named.font.borrow().get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0));
|
||||
let selected = match dep {
|
||||
Some(Obj::Dict(d)) => d,
|
||||
_ => named.font.clone(),
|
||||
};
|
||||
let mut chars = named.codes.text.chars();
|
||||
for &(code, n) in &named.codes.codes {
|
||||
let mut one = NamedCid { codes: Decoded::default(), font: named.font.clone(), vertical: named.vertical, ems: named.ems };
|
||||
one.codes.push(code, chars.by_ref().take(n));
|
||||
let v = self.named_cid_width(&one, &one.codes)?;
|
||||
self.push(Obj::Int(i64::from(code & 0xff)))?;
|
||||
self.push_num(v.x)?;
|
||||
self.push_num(v.y)?;
|
||||
self.cshow = Some(CShowFont { selected: selected.clone(), root: composite.clone(), code: one });
|
||||
self.g.font = Some(selected.clone());
|
||||
let go = self.body(proc);
|
||||
self.g.font = root.clone();
|
||||
if !go? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How far codes `codes` of type `named` move the current point (user space) when
|
||||
/// [`Self::show_named_cid`] shows them with no extra spacing.
|
||||
fn named_cid_width(&mut self, named: &NamedCid, codes: &Decoded) -> Res<Vec2> {
|
||||
|
||||
@@ -755,6 +755,28 @@ fn a_gradient_fill_and_its_stroke_are_one_object() {
|
||||
assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
/// An RGB gradient stop's CMYK equivalent is the one of its RGB values as written (to four
|
||||
/// decimals), so the reopened file writes the same stops.
|
||||
#[test]
|
||||
fn rgb_stops_are_written_again_unchanged() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let rgb = |i: u16| {
|
||||
let v = |s: f32| (f32::from(i) * s + 0.123_456_7).fract();
|
||||
Color::rgb(v(0.137_911_3), v(0.271_828_2), v(0.314_159_3))
|
||||
};
|
||||
let stops = (0..12u16).map(|i| GradientStop::new(f32::from(i) / 11.0, rgb(i))).collect();
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), paint, Paint::None);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let stops = |bytes: Vec<u8>| String::from_utf8(bytes).unwrap().lines().filter(|l| l.ends_with("%_Bs")).map(String::from).collect::<Vec<_>>();
|
||||
let first = write(&doc).bytes;
|
||||
let again = write(&crate::import(&first).unwrap().document).bytes;
|
||||
let (first, again) = (stops(first), stops(again));
|
||||
assert_eq!(first.len(), 12, "{first:?}");
|
||||
assert_eq!(again, first);
|
||||
}
|
||||
|
||||
/// A gradient from white to #1b2a6b, the colors of #2687's circle, placed at `geom` (None: fitted
|
||||
/// to the object).
|
||||
fn white_to_navy(kind: GradientKind, geom: Option<vectorcraft_color::GradientGeom>) -> Paint {
|
||||
|
||||
@@ -633,7 +633,8 @@ impl<'a> Writer<'a> {
|
||||
// Grey is ink coverage; the format's grey is lightness.
|
||||
Color::Gray { k } => Ink::Gray(unit(1.0 - k)),
|
||||
Color::Cmyk { c, m, y, k } => Ink::Cmyk([c, m, y, k].map(unit)),
|
||||
Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), [r, g, b].map(unit)),
|
||||
// The CMYK equivalent of the RGB values as written.
|
||||
Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(&ai_ops::as_written(*color), intent).map(unit), [r, g, b].map(unit)),
|
||||
Color::Lab { .. } => {
|
||||
let rgb = color.to_rgb().map(unit);
|
||||
Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), rgb)
|
||||
|
||||
@@ -36,6 +36,11 @@ pub(crate) fn push_num(s: &mut String, v: f64) {
|
||||
push_num_places(s, v, 4);
|
||||
}
|
||||
|
||||
/// The value [`num`]`(v)` stands for, as a reader parses it back.
|
||||
pub(crate) fn written(v: f64) -> f64 {
|
||||
num(v).parse().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not
|
||||
/// finite).
|
||||
pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) {
|
||||
|
||||
@@ -182,7 +182,19 @@ pub fn insert_at(sp: &SubPath, places: &[(usize, f64)]) -> (SubPath, BTreeSet<us
|
||||
out.anchors.push(a);
|
||||
out.anchors.extend(added.remove(&i).unwrap_or_default());
|
||||
}
|
||||
let added_before = |j: usize| inner.range(..j).map(|(_, v)| v.len()).sum::<usize>();
|
||||
// How many anchors were added before each anchor, counted in one pass for all of them.
|
||||
let mut before = vec![0usize; n + 1];
|
||||
for (&seg, ts) in &inner {
|
||||
if let Some(b) = before.get_mut(seg + 1) {
|
||||
*b += ts.len();
|
||||
}
|
||||
}
|
||||
let mut sum = 0;
|
||||
for b in &mut before {
|
||||
sum += *b;
|
||||
*b = sum;
|
||||
}
|
||||
let added_before = |j: usize| before.get(j).copied().unwrap_or(0);
|
||||
let mut marks: BTreeSet<usize> = at_anchor.into_iter().map(|j| j + added_before(j)).collect();
|
||||
for (&seg, ts) in &inner {
|
||||
let base = seg + added_before(seg);
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Blend widgets: two handles beside the spine of one selected blend, at the point halfway along
|
||||
//! the spine, one 16 screen pixels above it and one 16 pixels below it. Where other artwork crowds
|
||||
//! that spot (the boxes of three or more other visible objects hold it), a widget moves further
|
||||
//! out in 16-pixel steps, up to 144 pixels, to the first spot that is not crowded, and the line
|
||||
//! the canvas draws from the halfway point to the widget grows with it. The upper one sets the
|
||||
//! blend's color shift (`colorShift`, -100 to 100) and the lower one its easing strength (`ramp`,
|
||||
//! 0 to 100). The lower one shows only when the steps follow a spine (a replaced or edited spine,
|
||||
//! or Align to Path), where easing moves them; a blend made without a spine shows neither. A drag along the spine's direction at the press
|
||||
//! changes the value by one per screen pixel, starting from the value at the press, and the arrow
|
||||
//! keys during the drag change it by one (Shift: by ten); Escape or a switch to another tool
|
||||
//! cancels the drag and puts the value back. Each drag previews
|
||||
//! `object.blend.setEasing` and is one undo step, or none when the value ends where it started; a
|
||||
//! click changes nothing. The Selection and Blend tools hit the widgets and the canvas draws them
|
||||
//! from the same geometry.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::live::{Spine, blend_spine};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
use crate::{Action, Overlay, PointerEvent, ToolContext};
|
||||
|
||||
/// How far (screen px) a widget's center sits above or below the spine's halfway point.
|
||||
pub const OFFSET_PX: f64 = 16.0;
|
||||
/// The widget's radius (screen px), as the canvas draws it.
|
||||
pub const RADIUS_PX: f32 = 4.0;
|
||||
/// How near (screen px) the pointer must be to hit a widget.
|
||||
const HIT_PX: f64 = 5.0;
|
||||
/// Points of value per screen pixel of drag along the spine.
|
||||
const PER_PX: f64 = 1.0;
|
||||
/// The undo step a drag records, as the Blend panel's changes do.
|
||||
const LABEL: &str = "Blend Options";
|
||||
/// How many [`OFFSET_PX`] steps further out a widget may move from a crowded spot.
|
||||
const CLEAR_STEPS: usize = 8;
|
||||
/// A spot is crowded when the boxes of this many other visible objects hold it.
|
||||
const CROWDED: usize = 3;
|
||||
/// The most nodes looked at to find what crowds the widgets, topmost first.
|
||||
const LOOK: usize = 1024;
|
||||
|
||||
/// What a blend widget sets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BlendControl {
|
||||
/// The upper widget: the color shift.
|
||||
Appearance,
|
||||
/// The lower widget: the easing strength.
|
||||
Easing,
|
||||
}
|
||||
|
||||
impl BlendControl {
|
||||
/// The `object.blend.setEasing` param this widget sets.
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "colorShift",
|
||||
Self::Easing => "ramp",
|
||||
}
|
||||
}
|
||||
|
||||
/// The values the param takes.
|
||||
fn range(self) -> (f64, f64) {
|
||||
match self {
|
||||
Self::Appearance => (-100.0, 100.0),
|
||||
Self::Easing => (0.0, 100.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One widget of the selected blend.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BlendWidget {
|
||||
pub id: NodeId,
|
||||
pub control: BlendControl,
|
||||
/// The spine's halfway point, in the document.
|
||||
pub on: Point,
|
||||
/// The widget's center in the document: [`OFFSET_PX`] from `on`, or further out where other
|
||||
/// artwork crowds that spot.
|
||||
pub at: Point,
|
||||
/// The spine's direction at `on` (unit length, in the document).
|
||||
pub along: Vec2,
|
||||
/// The value the widget sets, as it is now.
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
impl BlendWidget {
|
||||
/// The widgets of the selection at `zoom` (screen px per document point), with `up` the
|
||||
/// document direction of the screen's up: the Appearance widget, then the Easing widget. Empty
|
||||
/// unless the selection is one whole blend, visible and unlocked, with two or more keys and a
|
||||
/// spine of some length, and no anchors are selected. Each widget sits at the first spot out
|
||||
/// from the spine, in [`OFFSET_PX`] steps, that the boxes of fewer than three other visible
|
||||
/// objects hold, at most nine steps out; where every spot is crowded it stays at the first.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64, up: Vec2) -> Vec<Self> {
|
||||
let up_len = up.hypot();
|
||||
let usable = |v: f64| v.is_finite() && v > 0.0;
|
||||
if !selection.anchors.is_empty() || selection.symbol_part.is_some() || !usable(zoom) || !usable(up_len) {
|
||||
return vec![];
|
||||
}
|
||||
let [id] = selection.objects.as_slice() else { return vec![] };
|
||||
let Some(NodeKind::Blend { children, spec }) = doc.node(*id).map(|n| &n.kind) else { return vec![] };
|
||||
if children.len() < 2 || !doc.is_editable(*id) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(spine) = blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)) else { return vec![] };
|
||||
if !usable(spine.length()) {
|
||||
return vec![];
|
||||
}
|
||||
let (on, angle) = spine.at(0.5);
|
||||
let along = Vec2::new(angle.cos(), angle.sin());
|
||||
if !(on.x.is_finite() && on.y.is_finite() && along.x.is_finite() && along.y.is_finite()) {
|
||||
return vec![];
|
||||
}
|
||||
let lift = up * (OFFSET_PX / zoom / up_len);
|
||||
let reach = lift.hypot() * (CLEAR_STEPS + 1) as f64 + f64::from(RADIUS_PX) / zoom;
|
||||
let boxes = boxes_near(doc, *id, Rect::new(on.x - reach, on.y - reach, on.x + reach, on.y + reach));
|
||||
let clear = |side: Vec2| {
|
||||
let crowded = |p: Point| boxes.iter().filter(|b| b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1).count() >= CROWDED;
|
||||
(1..=CLEAR_STEPS + 1).map(|k| on + side * k as f64).find(|p| !crowded(*p)).unwrap_or(on + side)
|
||||
};
|
||||
let value = |v: f64, control: BlendControl| {
|
||||
let (lo, hi) = control.range();
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { 0.0 }
|
||||
};
|
||||
let shift = value(spec.color_shift, BlendControl::Appearance);
|
||||
let mut out = vec![Self { id: *id, control: BlendControl::Appearance, on, at: clear(lift), along, value: shift }];
|
||||
if spec.eases_along_spine() {
|
||||
let ramp = value(spec.ease.ramp * 100.0, BlendControl::Easing);
|
||||
out.push(Self { id: *id, control: BlendControl::Easing, on, at: clear(-lift), along, value: ramp });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The widgets the tool in `cx` can drag.
|
||||
pub fn for_tool(cx: &ToolContext) -> Vec<Self> {
|
||||
Self::of(cx.doc, cx.selection, cx.zoom, screen_up(cx))
|
||||
}
|
||||
|
||||
/// The widget of `cx` nearest `p` within reach.
|
||||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
let d = |w: &Self| w.at.distance(p);
|
||||
Self::for_tool(cx).into_iter().filter(|w| d(w) <= cx.tol(HIT_PX)).min_by(|a, b| d(a).total_cmp(&d(b)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The document direction of the screen's up (the page's up without a window).
|
||||
fn screen_up(cx: &ToolContext) -> Vec2 {
|
||||
cx.screen.map(|s| -s.down).filter(|v| v.x.is_finite() && v.y.is_finite() && v.hypot() > 0.0).unwrap_or(Vec2::new(0.0, -1.0))
|
||||
}
|
||||
|
||||
/// The geometric boxes of the visible objects other than blend `skip` that reach into `region`.
|
||||
/// The walk goes into visible layers and groups without a clipping path, topmost first, and looks
|
||||
/// at no more than [`LOOK`] nodes; any other object counts by its own box.
|
||||
fn boxes_near(doc: &Document, skip: NodeId, region: Rect) -> Vec<Rect> {
|
||||
let reaches = |b: &Rect| b.x0 <= region.x1 && region.x0 <= b.x1 && b.y0 <= region.y1 && region.y0 <= b.y1;
|
||||
// Each node is queued at most once, so `room` bounds both the queue and the walk; a container
|
||||
// with more members than there is room for queues its topmost ones.
|
||||
fn queue<'a>(nodes: &'a [std::sync::Arc<Node>], skip: NodeId, room: &mut usize) -> impl Iterator<Item = &'a Node> {
|
||||
let take = nodes.len().min(*room);
|
||||
*room -= take;
|
||||
nodes.iter().skip(nodes.len() - take).filter(move |c| c.visible && c.id != skip).map(|c| &**c)
|
||||
}
|
||||
let mut room = LOOK;
|
||||
let mut stack: Vec<&Node> = queue(&doc.layers, skip, &mut room).collect();
|
||||
let mut out = vec![];
|
||||
while let Some(n) = stack.pop() {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
stack.extend(queue(children, skip, &mut room));
|
||||
}
|
||||
_ => out.extend(n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && reaches(b))),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is `p` over a blend widget?
|
||||
pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
|
||||
BlendWidget::at_point(cx, p).is_some()
|
||||
}
|
||||
|
||||
/// Dragging a blend widget: its value follows the pointer along the spine's direction at the
|
||||
/// press.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlendWidgetDrag {
|
||||
widget: BlendWidget,
|
||||
start: Point,
|
||||
/// The pointer's last position.
|
||||
at: Point,
|
||||
/// What the arrow keys added.
|
||||
keys: f64,
|
||||
began: bool,
|
||||
value: f64,
|
||||
}
|
||||
|
||||
impl BlendWidgetDrag {
|
||||
/// The drag has left the click: the value is changing.
|
||||
pub fn adjusting(&self) -> bool {
|
||||
self.began
|
||||
}
|
||||
|
||||
/// Start a drag when the press `ev` is on a blend widget of the selection.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = BlendWidget::at_point(cx, ev.pos)?;
|
||||
Some(Self { widget, start: ev.pos, at: ev.pos, keys: 0.0, began: false, value: widget.value })
|
||||
}
|
||||
|
||||
/// The value at the press plus the pointer's move along the spine, before the keys' steps
|
||||
/// and the range.
|
||||
fn dragged(&self, cx: &ToolContext) -> f64 {
|
||||
let moved = (self.at - self.start).dot(self.widget.along) * cx.zoom;
|
||||
self.widget.value + if moved.is_finite() { (moved * PER_PX).round() } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The value for the pointer at `self.at` and the keys pressed, within the range.
|
||||
fn value_now(&self, cx: &ToolContext) -> f64 {
|
||||
let (lo, hi) = self.widget.control.range();
|
||||
(self.dragged(cx) + self.keys).clamp(lo, hi)
|
||||
}
|
||||
|
||||
/// The actions that show the value now: the drag's start when it begins, then a preview.
|
||||
fn preview(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
self.began = true;
|
||||
out.push(Action::Begin(LABEL.into()));
|
||||
}
|
||||
self.value = self.value_now(cx);
|
||||
let mut params = json!({ "id": self.widget.id.0 });
|
||||
params[self.widget.control.key()] = json!(self.value);
|
||||
out.push(Action::Preview("object.blend.setEasing".into(), params));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
self.at = p;
|
||||
if !self.began && p.distance(self.start) < cx.tol(3.0) {
|
||||
return vec![];
|
||||
}
|
||||
self.preview(cx)
|
||||
}
|
||||
|
||||
/// An arrow key during the drag: one point more (`more`) or less, ten with Shift.
|
||||
pub fn step(&mut self, cx: &ToolContext, more: bool, big: bool) -> Vec<Action> {
|
||||
let by = if big { 10.0 } else { 1.0 };
|
||||
// The step goes from the value shown, so past either end (by keys or by the drag) the way
|
||||
// back starts at once.
|
||||
let (lo, hi) = self.widget.control.range();
|
||||
let stepped = (self.value_now(cx) + if more { by } else { -by }).clamp(lo, hi);
|
||||
self.keys = stepped - self.dragged(cx);
|
||||
self.preview(cx)
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
/// The value next to the pointer.
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if !self.began || !cx.measurement_labels {
|
||||
return vec![];
|
||||
}
|
||||
vec![Overlay::Measure { p: self.at, text: format!("{:.0}%", self.value) }]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::select::SelectionTool;
|
||||
use crate::testutil::*;
|
||||
use crate::{Mods, PointerKind, Tool, ToolKey};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpec};
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::{PathData, Rect, SubPath, shapes};
|
||||
|
||||
const UP: Vec2 = Vec2::new(0.0, -1.0);
|
||||
|
||||
fn square(d: &mut Document, x: f64, y: f64) -> Arc<Node> {
|
||||
let id = d.alloc_id();
|
||||
Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()))
|
||||
}
|
||||
|
||||
/// A blend of squares centered at `centers`, with `spec`, selected.
|
||||
fn blend_doc(centers: &[(f64, f64)], spec: BlendSpec) -> (Document, NodeId, Selection) {
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let l = d.layers[0].id;
|
||||
let children = centers.iter().map(|(x, y)| square(&mut d, x - 10.0, y - 10.0)).collect();
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Blend { children, spec: Box::new(spec) })).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
(d, id, s)
|
||||
}
|
||||
|
||||
/// Steps along a spine (Align to Path), eased In Out at 40%, color shift -20.
|
||||
fn eased() -> BlendSpec {
|
||||
BlendSpec {
|
||||
orientation: BlendOrientation::AlignToPath,
|
||||
ease: BlendEase { kind: BlendEaseType::InOut, ramp: 0.4 },
|
||||
color_shift: -20.0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
|
||||
PointerEvent::new(kind, p.x, p.y)
|
||||
}
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_widgets_sit_above_and_below_the_spines_halfway_point() {
|
||||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||||
assert_eq!(w.iter().map(|w| w.control).collect::<Vec<_>>(), [BlendControl::Appearance, BlendControl::Easing]);
|
||||
assert!(w.iter().all(|w| w.id == id && near(w.on, Point::new(200.0, 200.0))), "{w:?}");
|
||||
assert!(near(w[0].at, Point::new(200.0, 184.0)) && near(w[1].at, Point::new(200.0, 216.0)), "{w:?}");
|
||||
assert_eq!((w[0].value, w[1].value), (-20.0, 40.0));
|
||||
// 16 screen pixels at any zoom, and above on the screen in a turned view.
|
||||
assert!(near(BlendWidget::of(&d, &s, 4.0, UP)[0].at, Point::new(200.0, 196.0)));
|
||||
let turned = BlendWidget::of(&d, &s, 1.0, Vec2::new(1.0, 0.0));
|
||||
assert!(near(turned[0].at, Point::new(216.0, 200.0)) && near(turned[1].at, Point::new(184.0, 200.0)), "{turned:?}");
|
||||
// Halfway along by length: three keys spaced unevenly.
|
||||
let (d, _, s) = blend_doc(&[(0.0, 0.0), (100.0, 0.0), (100.0, 300.0)], eased());
|
||||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].on, Point::new(100.0, 100.0)));
|
||||
}
|
||||
|
||||
/// Adds a filled rectangle from `(x0, y0)` to `(x1, y1)` on top of the first layer.
|
||||
fn cover(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64) -> NodeId {
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let at = d.layers[0].children().map_or(0, |c| c.len());
|
||||
d.insert(Some(l), at, Node::path(id, shapes::rectangle(Rect::new(x0, y0, x1, y1)), Appearance::default_art())).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_widget_moves_out_from_crowded_artwork_and_its_line_follows() {
|
||||
let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
// Two objects under the Appearance widget's spot do not crowd it.
|
||||
for _ in 0..2 {
|
||||
cover(&mut d, 190.0, 175.0, 210.0, 195.0);
|
||||
}
|
||||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0)));
|
||||
// A third does: the widget moves 16 px further out, past them, and the Easing widget below
|
||||
// stays where it is. The halfway point it joins stays the same.
|
||||
let third = cover(&mut d, 190.0, 175.0, 210.0, 195.0);
|
||||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||||
assert!(near(w[0].at, Point::new(200.0, 168.0)) && near(w[0].on, Point::new(200.0, 200.0)), "{w:?}");
|
||||
assert!(near(w[1].at, Point::new(200.0, 216.0)), "{w:?}");
|
||||
// A hidden object does not count.
|
||||
d.node_mut(third).unwrap().visible = false;
|
||||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0)));
|
||||
// The walk looks at the topmost 1024 nodes only: with more objects above them, the covers
|
||||
// below are not looked at.
|
||||
d.node_mut(third).unwrap().visible = true;
|
||||
let mut many = d.clone();
|
||||
for i in 0..LOOK {
|
||||
let x = (i % 30) as f64 * 25.0;
|
||||
cover(&mut many, x, 500.0, x + 10.0, 510.0);
|
||||
}
|
||||
assert!(near(BlendWidget::of(&many, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0)));
|
||||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 168.0)));
|
||||
// Crowded all the way out (past 144 px): the widget stays at the first spot.
|
||||
let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
for _ in 0..3 {
|
||||
cover(&mut d, 150.0, 0.0, 250.0, 199.0);
|
||||
}
|
||||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0)));
|
||||
// The blend's own keys never crowd it: three 100 pt keys that all hold both spots.
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let l = d.layers[0].id;
|
||||
let big = |d: &mut Document, x: f64| {
|
||||
let id = d.alloc_id();
|
||||
Arc::new(Node::path(id, shapes::rectangle(Rect::new(x - 50.0, 150.0, x + 50.0, 250.0)), Appearance::default_art()))
|
||||
};
|
||||
let children = vec![big(&mut d, 150.0), big(&mut d, 200.0), big(&mut d, 250.0)];
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Blend { children, spec: Box::new(eased()) })).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||||
assert!(near(w[0].at, Point::new(200.0, 184.0)) && near(w[1].at, Point::new(200.0, 216.0)), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_easing_widget_needs_steps_that_follow_a_spine() {
|
||||
// The straight lines between the keys with Align to Page: the steps do not follow a spine.
|
||||
let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec::default());
|
||||
assert_eq!(BlendWidget::of(&d, &s, 1.0, UP).iter().map(|w| w.control).collect::<Vec<_>>(), [BlendControl::Appearance]);
|
||||
// A replaced spine, closed, with the keys spread along it.
|
||||
let spine = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 200.0)], true));
|
||||
let (d, _, s) = blend_doc(&[(0.0, 0.0), (200.0, 0.0)], BlendSpec { spine: Some(spine), ..Default::default() });
|
||||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!(w[0].on.x.is_finite() && w[0].on.y.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_widgets_for_a_selection_they_cannot_edit() {
|
||||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
assert!(BlendWidget::of(&d, &Selection::default(), 1.0, UP).is_empty(), "nothing selected");
|
||||
assert!(BlendWidget::of(&d, &s, 0.0, UP).is_empty() && BlendWidget::of(&d, &s, f64::NAN, UP).is_empty(), "no zoom");
|
||||
assert!(BlendWidget::of(&d, &s, 1.0, Vec2::ZERO).is_empty(), "no up");
|
||||
// Two blends, or a blend and another object.
|
||||
let (mut two, a, _) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
let l = two.layers[0].id;
|
||||
let keys = vec![square(&mut two, 0.0, 400.0), square(&mut two, 200.0, 400.0)];
|
||||
let b = two.alloc_id();
|
||||
two.insert(Some(l), 0, Node::new(b, NodeKind::Blend { children: keys, spec: Box::new(eased()) })).unwrap();
|
||||
let mut both = Selection::default();
|
||||
both.add(a);
|
||||
both.add(b);
|
||||
assert!(BlendWidget::of(&two, &both, 1.0, UP).is_empty(), "two blends");
|
||||
// Locked or hidden.
|
||||
for hide in [false, true] {
|
||||
let mut d = d.clone();
|
||||
let n = d.node_mut(id).unwrap();
|
||||
(n.visible, n.locked) = (!hide, !hide);
|
||||
assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "hidden: {hide}");
|
||||
}
|
||||
// A key object selected on its own is not the whole blend.
|
||||
let NodeKind::Blend { children, .. } = &d.node(id).unwrap().kind else { panic!("not a blend") };
|
||||
let mut key = Selection::default();
|
||||
key.add(children[0].id);
|
||||
assert!(BlendWidget::of(&d, &key, 1.0, UP).is_empty(), "a key");
|
||||
// Keys on one spot: the spine has no length.
|
||||
let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0)], eased());
|
||||
assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no length");
|
||||
// One key.
|
||||
let (d, _, s) = blend_doc(&[(100.0, 200.0)], eased());
|
||||
assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "one key");
|
||||
// A blend made without a spine (Create Spine › Never), Align to Path included.
|
||||
let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() });
|
||||
assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine");
|
||||
}
|
||||
|
||||
/// A drag of the Easing widget along the spine changes the ramp by one per pixel from its
|
||||
/// value at the press, in one undo step, without changing the type or the color shift.
|
||||
#[test]
|
||||
fn dragging_the_easing_widget_sets_the_ramp_from_its_value() {
|
||||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
let at = Point::new(200.0, 216.0);
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty());
|
||||
// A small move first: no jump, the value stays at the press's 40.
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(1.0, 1.0))).is_empty());
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(3.0, 30.0)));
|
||||
assert_eq!(a[0], Action::Begin(LABEL.into()));
|
||||
assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 43.0})), "across the spine counts for nothing");
|
||||
assert!(t.transforming());
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "43%")));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-70.0, 0.0)));
|
||||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 0.0}))], "clamped at 0");
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(500.0, 0.0)));
|
||||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 100.0}))], "clamped at 100");
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at)), vec![Action::Commit]);
|
||||
// A click that doesn't move sends nothing.
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty());
|
||||
}
|
||||
|
||||
/// The Appearance widget sets the color shift alone, and at 4× zoom a pixel is a quarter point.
|
||||
#[test]
|
||||
fn dragging_the_appearance_widget_sets_the_color_shift() {
|
||||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
let p = paint();
|
||||
let c = ToolContext { zoom: 4.0, ..cx(&d, &s, &p) };
|
||||
let mut t = SelectionTool::default();
|
||||
let at = Point::new(200.0, 196.0);
|
||||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-5.0, 0.0)));
|
||||
assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": -40.0})));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-50.0, 0.0)));
|
||||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": -100.0}))]);
|
||||
}
|
||||
|
||||
/// The drag follows the spine's direction at the press: along a vertical spine, down adds.
|
||||
#[test]
|
||||
fn the_drag_follows_the_spines_direction() {
|
||||
let (d, id, s) = blend_doc(&[(200.0, 100.0), (200.0, 300.0)], eased());
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, w[1].at));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, w[1].at + Vec2::new(40.0, 10.0)));
|
||||
assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 50.0})));
|
||||
}
|
||||
|
||||
/// Switching away from the Selection or Blend tool during a drag cancels it, and after a press
|
||||
/// that has not moved there is nothing to put back.
|
||||
#[test]
|
||||
fn a_tool_switch_cancels_the_drag() {
|
||||
let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let at = Point::new(200.0, 216.0);
|
||||
let tools: [Box<dyn Tool>; 2] = [Box::new(SelectionTool::default()), Box::new(crate::meshblend::BlendTool::default())];
|
||||
for mut t in tools {
|
||||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(20.0, 0.0))).len(), 2);
|
||||
assert_eq!(t.deactivate(&c), vec![Action::Cancel]);
|
||||
assert!(!t.busy() && !t.transforming());
|
||||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||||
assert!(t.busy());
|
||||
assert!(t.deactivate(&c).is_empty());
|
||||
assert!(!t.busy());
|
||||
}
|
||||
}
|
||||
|
||||
/// The arrow keys during a drag change the value by one, ten with Shift, and Escape cancels.
|
||||
#[test]
|
||||
fn arrow_keys_step_and_escape_cancels() {
|
||||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
let at = Point::new(200.0, 216.0);
|
||||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||||
let a = t.key(&c, ToolKey::Up, Mods::default());
|
||||
assert_eq!(a, vec![Action::Begin(LABEL.into()), Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 41.0}))]);
|
||||
let a = t.key(&c, ToolKey::Left, Mods { shift: true, ..Default::default() });
|
||||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 31.0}))]);
|
||||
for _ in 0..10 {
|
||||
t.key(&c, ToolKey::Down, Mods { shift: true, ..Default::default() });
|
||||
}
|
||||
let a = t.key(&c, ToolKey::Right, Mods::default());
|
||||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 1.0}))], "steps past 0 are not kept");
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(!t.busy() && !t.transforming());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty(), "the release commits nothing");
|
||||
}
|
||||
|
||||
/// A double-click on a widget with the Selection tool does nothing to the artwork under it.
|
||||
#[test]
|
||||
fn a_double_click_on_a_widget_leaves_the_artwork_under_it_alone() {
|
||||
let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
cover(&mut d, 190.0, 206.0, 210.0, 226.0);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let at = Point::new(200.0, 216.0);
|
||||
assert!(over_widget(&c, at));
|
||||
let mut t = SelectionTool::default();
|
||||
assert!(t.pointer(&c, &ev(PointerKind::DoubleClick, at)).is_empty());
|
||||
// Beside the widget, the double-click reaches the artwork.
|
||||
assert!(!t.pointer(&c, &ev(PointerKind::DoubleClick, Point::new(192.0, 224.0))).is_empty());
|
||||
}
|
||||
|
||||
/// After a drag past either end, the first arrow key steps back from the value shown.
|
||||
#[test]
|
||||
fn an_arrow_key_after_a_drag_past_an_end_steps_from_the_value_shown() {
|
||||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let at = Point::new(200.0, 216.0);
|
||||
let ramp = |v: f64| vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": v}))];
|
||||
for (moved, back, shown) in [(200.0, ToolKey::Left, 99.0), (-200.0, ToolKey::Right, 1.0)] {
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||||
t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(moved, 0.0)));
|
||||
assert_eq!(t.key(&c, back, Mods::default()), ramp(shown), "{moved}");
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at + Vec2::new(moved, 0.0))), vec![Action::Commit]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod addons;
|
||||
pub mod bbox;
|
||||
pub mod blendwidget;
|
||||
pub mod builder;
|
||||
pub mod catalog;
|
||||
pub mod corners;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! point of a path blends from that point (`starts`). Each further click adds the object as one
|
||||
//! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens
|
||||
//! Blend Options (dialog `blendOptions`). Clicking empty canvas or pressing Escape starts over.
|
||||
//! A press on a widget of the selected blend drags it ([`crate::blendwidget`]).
|
||||
//! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through
|
||||
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
|
||||
//! new point takes the current fill color in its own model, unless Shift); drag a mesh point,
|
||||
@@ -19,6 +20,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::blendwidget::{self, BlendWidgetDrag};
|
||||
use crate::meshedit::{MeshEdit, grid_of};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
@@ -51,6 +53,8 @@ pub struct BlendTool {
|
||||
/// `first` and `chained` when the button went down: a dropped press puts them back, unless
|
||||
/// the press picked an object or made a blend.
|
||||
before_press: Option<(Option<Pick>, bool)>,
|
||||
/// A press on a widget of the selected blend, and its drag.
|
||||
widget: Option<BlendWidgetDrag>,
|
||||
}
|
||||
|
||||
/// The anchor of the first subpath of path or compound path `id` (its first member's) nearest
|
||||
@@ -98,6 +102,19 @@ impl Tool for BlendTool {
|
||||
if self.first.is_some_and(|(a, _)| cx.doc.node(a).is_none()) {
|
||||
(self.first, self.chained) = (None, false);
|
||||
}
|
||||
// The selected blend's widgets take a press before the picks, and its drag.
|
||||
match (ev.kind, &mut self.widget) {
|
||||
(PointerKind::Drag, Some(d)) => return d.drag(cx, ev.pos),
|
||||
(PointerKind::Up, Some(_)) => return self.widget.take().map(BlendWidgetDrag::finish).unwrap_or_default(),
|
||||
(PointerKind::DoubleClick, _) if blendwidget::over_widget(cx, ev.pos) => return vec![],
|
||||
(PointerKind::Down, _) if !ev.mods.alt => {
|
||||
if let Some(d) = BlendWidgetDrag::hit(cx, ev) {
|
||||
self.widget = Some(d);
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::DoubleClick => options(self),
|
||||
PointerKind::Down if ev.mods.alt => options(self),
|
||||
@@ -131,6 +148,9 @@ impl Tool for BlendTool {
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if let Some(d) = &self.widget {
|
||||
return d.overlays(cx);
|
||||
}
|
||||
let Some((id, start)) = self.first(cx) else { return vec![] };
|
||||
let Some(n) = cx.doc.node(id) else { return vec![] };
|
||||
let at = match (start, &n.kind) {
|
||||
@@ -144,6 +164,9 @@ impl Tool for BlendTool {
|
||||
vec![Overlay::Anchor { p, color: FEEDBACK, filled: true, size: 6.0 }]
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||||
if self.widget.is_some() || blendwidget::over_widget(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
match pick(cx, p) {
|
||||
Some((_, Some(_))) => Cursor::BlendAnchor,
|
||||
Some(_) => Cursor::BlendObject,
|
||||
@@ -151,15 +174,34 @@ impl Tool for BlendTool {
|
||||
}
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.first.is_some()
|
||||
self.first.is_some() || self.widget.is_some()
|
||||
}
|
||||
fn transforming(&self) -> bool {
|
||||
self.widget.as_ref().is_some_and(BlendWidgetDrag::adjusting)
|
||||
}
|
||||
/// The arrows while a blend widget is pressed: its value.
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.widget.is_some() && matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right)
|
||||
}
|
||||
/// A first pick, or adding to the blend just made: Escape ends either.
|
||||
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
|
||||
self.first(cx).is_some()
|
||||
}
|
||||
/// Escape drops the object picked first and stops adding to the blend just made, so the next
|
||||
/// click picks a new first object. The document and the selection stay as they are.
|
||||
fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
/// click picks a new first object. The document and the selection stay as they are. While a
|
||||
/// blend widget is pressed, the arrows change its value and Escape cancels its drag instead.
|
||||
fn key(&mut self, cx: &ToolContext, key: crate::ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if let Some(d) = &mut self.widget {
|
||||
return match key {
|
||||
ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right => d.step(cx, matches!(key, ToolKey::Up | ToolKey::Right), mods.shift),
|
||||
ToolKey::Escape => {
|
||||
let began = d.adjusting();
|
||||
self.widget = None;
|
||||
if began { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
}
|
||||
if key == crate::ToolKey::Escape {
|
||||
(self.first, self.chained, self.before_press) = (None, false, None);
|
||||
}
|
||||
@@ -172,11 +214,18 @@ impl Tool for BlendTool {
|
||||
if let Some((first, chained)) = self.before_press.take() {
|
||||
(self.first, self.chained) = (first, chained);
|
||||
}
|
||||
vec![]
|
||||
// A dropped widget drag puts its value back.
|
||||
match self.widget.take() {
|
||||
Some(d) if d.adjusting() => vec![Action::Cancel],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
(self.first, self.chained, self.before_press) = (None, false, None);
|
||||
vec![]
|
||||
match self.widget.take() {
|
||||
Some(d) if d.adjusting() => vec![Action::Cancel],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
/// Choosing the Blend tool again finishes the blend: it drops the object picked first and
|
||||
/// stops adding to the blend just made, so the next click picks a new first object. The
|
||||
|
||||
@@ -45,6 +45,7 @@ use vectorcraft_doc::{NodeId, OrientedBox};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::blendwidget::{self, BlendWidgetDrag};
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path};
|
||||
use crate::objects_on_path::{
|
||||
@@ -131,6 +132,8 @@ enum State {
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a live ellipse's pie widget.
|
||||
Pie(PieDrag),
|
||||
/// Dragging a selected blend's Appearance or Easing widget.
|
||||
BlendWidget(BlendWidgetDrag),
|
||||
/// Dragging an Objects on Path group's Rotate All widget.
|
||||
RotateAll(RotateAllDrag),
|
||||
/// Dragging an Objects on Path group's Space widget.
|
||||
@@ -757,15 +760,19 @@ impl Tool for SelectionTool {
|
||||
|
||||
/// E during a marquee drag switches the marquee between the objects it touches and only the
|
||||
/// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or
|
||||
/// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space, Move All or Select/Move widget, the brackets of type on a path, a mirror
|
||||
/// rotation, a marquee, a corner widget, a pie widget, a blend widget, the Rotate All, Space, Move All or Select/Move widget, the brackets of type on a path, a mirror
|
||||
/// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy
|
||||
/// is left, and the tool waits for the next press. With the pointer loaded with text, Escape
|
||||
/// drops the text.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
// Up and Down while a corner widget is pressed: the corners' kind (#2898).
|
||||
if let (ToolKey::Up | ToolKey::Down, State::Corner(c)) = (key, &mut self.state) {
|
||||
return c.step_kind(key == ToolKey::Up);
|
||||
}
|
||||
// The arrows while a blend widget is pressed: its value, by one (Shift: ten).
|
||||
if let (ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right, State::BlendWidget(d)) = (key, &mut self.state) {
|
||||
return d.step(cx, matches!(key, ToolKey::Up | ToolKey::Right), mods.shift);
|
||||
}
|
||||
if key == ToolKey::Letter('e')
|
||||
&& let State::Marquee { enclosed, .. } = &mut self.state
|
||||
{
|
||||
@@ -816,10 +823,23 @@ impl Tool for SelectionTool {
|
||||
out
|
||||
}
|
||||
|
||||
/// Switching tools during a blend widget's drag cancels it, as Escape does: its value goes back.
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
match std::mem::take(&mut self.state) {
|
||||
State::BlendWidget(d) if d.adjusting() => vec![Action::Cancel],
|
||||
State::BlendWidget(_) => vec![],
|
||||
other => {
|
||||
self.state = other;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
match self.state {
|
||||
State::Marquee { .. } => key == ToolKey::Letter('e'),
|
||||
State::Corner(_) => matches!(key, ToolKey::Up | ToolKey::Down),
|
||||
State::BlendWidget(_) => matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right),
|
||||
State::Loaded { .. } | State::Mirror(_) | State::GridRepeat(_) | State::NineSlice(_) => key == ToolKey::Escape,
|
||||
_ => false,
|
||||
}
|
||||
@@ -850,6 +870,7 @@ impl Tool for SelectionTool {
|
||||
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
|
||||
State::Corner(c) => c.adjusting(),
|
||||
State::Pie(d) => d.adjusting(),
|
||||
State::BlendWidget(d) => d.adjusting(),
|
||||
State::RotateAll(d) => d.adjusting(),
|
||||
State::Space(d) => d.adjusting(),
|
||||
State::MoveAll(d) => d.adjusting(),
|
||||
@@ -1024,6 +1045,15 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
d.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::BlendWidget(mut d)) => {
|
||||
let out = d.drag(cx, p);
|
||||
self.state = State::BlendWidget(d);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::BlendWidget(d)) => {
|
||||
self.state = State::Idle;
|
||||
d.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::RotateAll(mut d)) => {
|
||||
let out = d.drag(cx, p, m.shift);
|
||||
self.state = State::RotateAll(d);
|
||||
@@ -1122,6 +1152,7 @@ impl Tool for SelectionTool {
|
||||
}),
|
||||
State::Corner(c) => o.extend(c.overlays(cx)),
|
||||
State::Pie(d) => o.extend(d.overlays(cx)),
|
||||
State::BlendWidget(d) => o.extend(d.overlays(cx)),
|
||||
State::RotateAll(d) => o.extend(d.overlays(cx)),
|
||||
State::Space(d) => o.extend(d.overlays(cx)),
|
||||
State::MoveAll(d) => o.extend(d.overlays(cx)),
|
||||
@@ -1176,7 +1207,7 @@ impl Tool for SelectionTool {
|
||||
State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy,
|
||||
State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow },
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
State::Pie(_) => return Cursor::ArrowHollow,
|
||||
State::Pie(_) | State::BlendWidget(_) => return Cursor::ArrowHollow,
|
||||
State::RotateAll(_) => return Cursor::Rotate,
|
||||
State::Space(_) => return Cursor::ArrowHollow,
|
||||
State::MoveAll(_) | State::Member(_) => return Cursor::Move,
|
||||
@@ -1199,6 +1230,9 @@ impl Tool for SelectionTool {
|
||||
if near_selected && pie::over_widget(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if blendwidget::over_widget(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if over_rotate_all(cx, p) {
|
||||
return Cursor::Rotate;
|
||||
}
|
||||
@@ -1518,6 +1552,11 @@ impl SelectionTool {
|
||||
if let Some(a) = release_guide_at(cx, p, m) {
|
||||
return vec![a];
|
||||
}
|
||||
// A selected blend's widget takes a plain double-click: the artwork under it is neither
|
||||
// edited nor isolated.
|
||||
if blendwidget::over_widget(cx, p) {
|
||||
return vec![];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -1557,7 +1596,7 @@ impl SelectionTool {
|
||||
self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None };
|
||||
return Some(vec![]);
|
||||
}
|
||||
// 1. Live Corners widgets, pie widgets, the Rotate All, Space, Move All and Select/Move widgets and type on a path's brackets, then the
|
||||
// 1. Live Corners widgets, pie widgets, blend widgets, the Rotate All, Space, Move All and Select/Move widgets and type on a path's brackets, then the
|
||||
// bounding-box handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, false) {
|
||||
self.state = State::Corner(c);
|
||||
@@ -1567,6 +1606,10 @@ impl SelectionTool {
|
||||
self.state = State::Pie(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(d) = BlendWidgetDrag::hit(cx, ev) {
|
||||
self.state = State::BlendWidget(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(d) = RotateAllDrag::hit(cx, ev) {
|
||||
self.state = State::RotateAll(d);
|
||||
return Some(vec![]);
|
||||
|
||||
@@ -157,9 +157,12 @@ impl VectorcraftApp {
|
||||
let pending = self.pending_screenshots.remove(index);
|
||||
self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes);
|
||||
let roots = self.automation_roots.clone();
|
||||
let _ = pending
|
||||
.reply
|
||||
.send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data)));
|
||||
let reply = file_access::confine(roots.as_ref(), || {
|
||||
let mut reply = control::save_screenshot(self, &image, pending.path.as_deref(), pending.data);
|
||||
control::hide_in_deferred_reply(self, &mut reply, &json!({"path": pending.path}));
|
||||
reply
|
||||
});
|
||||
let _ = pending.reply.send(reply);
|
||||
}
|
||||
Err(e) => {
|
||||
let error = format!("the screenshot could not be used: {e}");
|
||||
|
||||
@@ -61,7 +61,7 @@ use outline::KEY_OUTLINE;
|
||||
use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn};
|
||||
#[cfg(test)]
|
||||
use overlays::selected_anchor;
|
||||
use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay};
|
||||
use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay};
|
||||
pub use panes::Panes;
|
||||
pub(crate) use panes::{consolidate, pane, tile, tiled_area};
|
||||
use raster::{changed_pixels, pixel_region, upload};
|
||||
@@ -1207,6 +1207,9 @@ fn selection_visuals(
|
||||
hidden_chars_overlay(app, painter, xf);
|
||||
}
|
||||
}
|
||||
if !revolve_edit {
|
||||
blend_widgets_overlay(app, painter, xf);
|
||||
}
|
||||
// Edit › Spelling › Auto Spell Check: shown with the edges hidden too, as it marks the type
|
||||
// rather than the selection.
|
||||
if app.ui.view.auto_spell_check && !revolve_edit {
|
||||
|
||||
@@ -250,6 +250,26 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
|
||||
rotate_all_widget(p, xf, tool, &st.doc, &st.selection);
|
||||
}
|
||||
|
||||
/// The Appearance and Easing widgets of one selected blend (Selection and Blend tools), each joined
|
||||
/// by a line to the spine's halfway point: the Appearance widget hollow above it, the Easing widget
|
||||
/// filled below it. Drawn with the edges hidden too, as they are controls.
|
||||
pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
use vectorcraft_tools::blendwidget::{BlendControl, BlendWidget, RADIUS_PX};
|
||||
if !matches!(app.session.tool_id(), "selection" | "blend") {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
// The screen's up in the document, with the view turned by `xf.rot`.
|
||||
let up = vectorcraft_geom::Vec2::new(-xf.rot.sin(), -xf.rot.cos());
|
||||
for w in BlendWidget::of(&st.doc, &st.selection, xf.zoom, up) {
|
||||
let (on, c, color) = (xf.to_screen(w.on), xf.to_screen(w.at), c32(st.doc.layer_color(w.id)));
|
||||
p.line_segment([on, c], Stroke::new(1.0, color));
|
||||
let fill = if w.control == BlendControl::Easing { color } else { Color32::WHITE };
|
||||
p.circle_filled(c, RADIUS_PX, fill);
|
||||
p.circle_stroke(c, RADIUS_PX, Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Select/Move, Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one
|
||||
/// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's
|
||||
/// attachment point. It stays while dragged.
|
||||
|
||||
+887
-48
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,400 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value
|
||||
r
|
||||
}
|
||||
|
||||
/// A reply hides a hidden library's id and path at every occurrence, in an error that wraps them in
|
||||
/// spaces, brackets or quotes, follows them with more path text, or quotes the path with `{:?}`.
|
||||
#[test]
|
||||
fn a_reply_hides_a_hidden_library_whatever_surrounds_it() {
|
||||
let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned();
|
||||
let id = format!("loaded/{path}");
|
||||
let hidden = hiding(&[(&id, "loaded/b.svg")], &[]);
|
||||
let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")});
|
||||
hide_under_a_root("named-reply", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}");
|
||||
}
|
||||
|
||||
/// A [`super::Hiding`] with `hidden` ids, each with its shown id, and `readable` ids.
|
||||
fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding {
|
||||
let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect();
|
||||
hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
|
||||
super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect(), sent: Vec::new() }
|
||||
}
|
||||
|
||||
/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`.
|
||||
fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) {
|
||||
let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden));
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64`, `thumbnailBase64`
|
||||
/// or `thumbnail` (a library item's or a PDF page's PNG), stays as it is when it holds a hidden
|
||||
/// library's path. Text that is not base64 under such a key, an object or a list under it, and any
|
||||
/// other key ending in `Base64` are searched.
|
||||
#[test]
|
||||
fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() {
|
||||
let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]);
|
||||
let data = "QUJ+assets/iconREVGxQQ==";
|
||||
let mut reply = json!({"ok": true, "result": {
|
||||
"graphics": [{"name": "a", "thumbnail": data}, {"name": "b", "thumbnail": "assets/icon.png"}],
|
||||
"files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}],
|
||||
"status": "from assets/icon",
|
||||
"dataBase64": {"note": "from assets/icon"},
|
||||
"pngBase64": ["from assets/icon"],
|
||||
"otherBase64": data,
|
||||
}});
|
||||
hide_under_a_root("base64", &mut reply, &hidden);
|
||||
let files = &reply["result"]["files"][0];
|
||||
assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}");
|
||||
assert_eq!(files["pngBase64"], "icon.png", "{reply}");
|
||||
let graphics = &reply["result"]["graphics"];
|
||||
assert_eq!((&graphics[0]["thumbnail"], &graphics[1]["thumbnail"]), (&json!(data), &json!("icon.png")), "{reply}");
|
||||
assert_eq!(reply["result"]["status"], "from icon", "{reply}");
|
||||
assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}");
|
||||
assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}");
|
||||
assert_eq!(reply["result"]["otherBase64"], "QUJ+iconREVGxQQ==", "{reply}");
|
||||
}
|
||||
|
||||
/// A hidden path followed by more text stays only when the longer text is another loaded library's
|
||||
/// id: text after the path that the request may read, a `..` part and the part of the path before a
|
||||
/// space do not keep it.
|
||||
#[test]
|
||||
fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() {
|
||||
let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id()));
|
||||
let root = base.join("in");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned();
|
||||
let other = base.join("outside").join("b.svg").to_string_lossy().into_owned();
|
||||
let known = format!("loaded/{other}{}sub.svg", std::path::MAIN_SEPARATOR);
|
||||
let hidden = hiding(&[(&format!("loaded/{hidden_path}"), "loaded/b.svg"), (&format!("loaded/{other}"), "loaded/b.svg (2)")], &[&known]);
|
||||
let up = ["", "..", "..", "in", "a.svg"].join(std::path::MAIN_SEPARATOR_STR);
|
||||
let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")});
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}");
|
||||
// A readable library's id with a space after the hidden part stays whole.
|
||||
// A readable id with a `..` part does not keep the hidden part.
|
||||
let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg", "loaded//art/in/../ok/c.svg"]);
|
||||
let mut reply = json!({"error": "x loaded//art/in work/a.svg, /art/in work/a.svg and /art/in work; /art/in/../ok/c.svg"});
|
||||
hide_under_a_root("space-continuation", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "x loaded//art/in work/a.svg, /art/in work/a.svg and in work; in/../ok/c.svg", "{reply}");
|
||||
}
|
||||
|
||||
/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden
|
||||
/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform, the whole URL shown as
|
||||
/// the file name.
|
||||
#[test]
|
||||
fn a_reply_hides_a_path_in_any_case_and_escaped() {
|
||||
let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]);
|
||||
let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"});
|
||||
let pairs = super::hidden_text(&hidden);
|
||||
let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true };
|
||||
super::replace_in_value(&mut reply, &rewrite);
|
||||
assert_eq!(reply["error"], "from b.svg and b.svg", "{reply}");
|
||||
// Two libraries whose paths differ only in letter case keep their own ids.
|
||||
let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]);
|
||||
let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"});
|
||||
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two), hiding: &two, fold: true });
|
||||
assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}");
|
||||
// A readable id that begins with a hidden one stays whole in any case, `loaded/` included.
|
||||
let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]);
|
||||
let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"});
|
||||
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested), hiding: &nested, fold: true });
|
||||
assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}");
|
||||
let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"});
|
||||
hide_under_a_root("escaped", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "b.svg", "{reply}");
|
||||
let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]);
|
||||
let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"});
|
||||
hide_under_a_root("escaped-mixed", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "b.svg and b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}");
|
||||
// A `file:` URL without escapes is replaced whole too, with `localhost` or another host.
|
||||
let mut reply = json!({"error": "file:///libs/caf\u{e9}/b.svg and FILE://localhost/libs/caf%C3%A9/b.svg"});
|
||||
hide_under_a_root("plain-url", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "b.svg and b.svg", "{reply}");
|
||||
let mut reply = json!({"error": "file://remote/libs/caf\u{e9}/b.svg; file:/libs/caf\u{e9}/b.svg; file: x/libs/caf\u{e9}/b.svg"});
|
||||
hide_under_a_root("other-host", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "b.svg; b.svg; file: xb.svg", "{reply}");
|
||||
let hidden = hiding(&[("loaded///host/share/c.svg", "loaded/c.svg")], &[]);
|
||||
let mut reply = json!({"error": "file://host/share/c.svg, file:////host/share/c.svg, file://localhost//host/share/c.svg"});
|
||||
hide_under_a_root("host-url", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "c.svg, c.svg, c.svg", "{reply}");
|
||||
}
|
||||
|
||||
/// Only a string with a `%XX` escape is decoded before it is searched: a `%` that begins no escape
|
||||
/// leaves the text as it is, and the reply is the same either way.
|
||||
#[test]
|
||||
fn only_a_percent_escape_is_decoded() {
|
||||
for (text, escaped) in [("width=\"100%\"", false), ("50% off", false), ("%zz%4", false), ("%", false), ("a%2Fb", true), ("%fF", true)] {
|
||||
assert_eq!(super::has_percent_escape(text), escaped, "{text}");
|
||||
let decoded = super::decoded_with_spans(text).map(|(decoded, _)| decoded);
|
||||
assert_eq!(decoded.as_deref() != Some(text), escaped, "{text}: an escape alone changes the decoded text");
|
||||
}
|
||||
let hidden = hiding(&[("loaded//libs/b.svg", "loaded/b.svg")], &[]);
|
||||
let mut reply = json!({"error": "100% of /libs/b.svg and file:///libs%2Fb.svg"});
|
||||
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&hidden), hiding: &hidden, fold: false });
|
||||
assert_eq!(reply["error"], "100% of b.svg and b.svg", "{reply}");
|
||||
}
|
||||
|
||||
/// A path a failed request sent takes the place of a hidden library's id for the same path, written
|
||||
/// with the other separator, and in another letter case when matching in any case (as on Windows).
|
||||
#[test]
|
||||
fn a_sent_path_replaces_a_hidden_id_for_the_same_path() {
|
||||
let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]);
|
||||
hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true);
|
||||
let shown: Vec<&str> = hidden.hidden.iter().map(|(_, shown)| shown.as_str()).collect();
|
||||
assert_eq!(shown, ["loaded/B.svg", "loaded/b.svg"], "{hidden:?}");
|
||||
assert_eq!(hidden.sent, [("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())]);
|
||||
hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/x.svg".to_string())], false);
|
||||
assert_eq!((hidden.hidden[1].1.as_str(), hidden.sent.len()), ("loaded/b.svg", 2), "{hidden:?}");
|
||||
// A library loaded by a relative path, and the absolute path it reads as, sent: the sent path's
|
||||
// file name is shown for both, not the library's numbered id.
|
||||
let mut hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg (2)")], &[]);
|
||||
let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned();
|
||||
hidden.set_hidden(vec![(format!("loaded/{absolute}"), "loaded/icon.svg".to_string())], false);
|
||||
let mut reply = json!({"error": format!("can't read \"data/icon.svg\" or {absolute}")});
|
||||
hide_under_a_root("sent-absolute", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}");
|
||||
}
|
||||
|
||||
/// Outside Windows, a `\` in a native path is part of a name: a sent path that differs from a hidden
|
||||
/// library's path only by `/` in place of that `\` names another file, so the library keeps its id
|
||||
/// and a reply does not read either path as a spelling of the other.
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn a_backslash_in_a_native_path_keeps_two_paths_apart() {
|
||||
let mut hidden = hiding(&[(r"loaded//out/weird\name.svg", r"loaded/weird\name.svg")], &[]);
|
||||
hidden.set_hidden(vec![("loaded//out/weird/name.svg".to_string(), "loaded/name.svg".to_string())], false);
|
||||
assert_eq!(hidden.hidden[0].1, r"loaded/weird\name.svg", "{hidden:?}");
|
||||
let mut reply = json!({"error": r"no /out/weird\name.svg, /out/weird/name.svg or \out\weird\name.svg"});
|
||||
hide_under_a_root("native-backslash", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], r"no weird\name.svg, name.svg or \out\weird\name.svg", "{reply}");
|
||||
}
|
||||
|
||||
/// A sent path whose text overlaps part of a hidden library's path does not keep that library's path
|
||||
/// from being hidden: the hidden libraries are matched first.
|
||||
#[test]
|
||||
fn a_sent_path_does_not_stop_a_hidden_library_path_from_being_hidden() {
|
||||
let mut hidden = hiding(&[("loaded//home/secret/dir/lib", "loaded/lib")], &[]);
|
||||
hidden.set_hidden(vec![("loaded//dir/lib/sent-folder/longer".to_string(), "loaded/longer".to_string())], false);
|
||||
let mut reply = json!({"error": "can't read /home/secret/dir/lib/sent-folder/longer"});
|
||||
hide_under_a_root("sent-overlap", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "can't read lib/sent-folder/longer", "{reply}");
|
||||
}
|
||||
|
||||
/// A hidden relative path is also hidden where a reply shows it as the absolute path it reads as from
|
||||
/// the working folder.
|
||||
#[test]
|
||||
fn a_reply_hides_a_hidden_relative_path_written_as_absolute() {
|
||||
let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]);
|
||||
let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned();
|
||||
let mut reply = json!({"error": format!("can't read {absolute}")});
|
||||
hide_under_a_root("relative-absolute", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "can't read icon.svg", "{reply}");
|
||||
}
|
||||
|
||||
/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names
|
||||
/// another host), under `~` or starting with `../`, or as a relative path in a path param, are hidden
|
||||
/// by their file names; plain text and a path inside the root are not.
|
||||
#[test]
|
||||
fn a_request_sends_paths_that_an_error_hides() {
|
||||
let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id()));
|
||||
let root = base.join("in");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let inside = root.join("a.svg").to_string_lossy().into_owned();
|
||||
let keep = json!(["a/b", inside, "file://host/g.svg"]);
|
||||
let params = json!({"path": "/x/my lib/b.svg", "file": "outside/h.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep});
|
||||
let mut sent = Vec::new();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}"));
|
||||
let want = [
|
||||
pair("outside/h.svg", "h.svg"),
|
||||
pair("/y/c d.svg", "c d.svg"),
|
||||
pair("~/e.svg", "e.svg"),
|
||||
pair("../f.svg", "f.svg"),
|
||||
pair("//host/g.svg", "g.svg"),
|
||||
pair("/x/my lib/b.svg", "b.svg"),
|
||||
];
|
||||
assert_eq!(sent, want);
|
||||
}
|
||||
|
||||
/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a
|
||||
/// relative name and a path inside the root are not.
|
||||
#[test]
|
||||
fn an_absolute_path_outside_the_read_root_is_unreadable() {
|
||||
let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id()));
|
||||
std::fs::create_dir_all(base.join("in")).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
|
||||
let inside = base.join("in").join("a.svg").to_string_lossy().into_owned();
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
assert!(super::unreadable_absolute("/outside/root/x"));
|
||||
assert!(super::unreadable_absolute("file:///outside/root/x"));
|
||||
assert!(super::unreadable_absolute("FILE:///outside/root/x"));
|
||||
assert!(super::unreadable_absolute("file://other-host/share/x"));
|
||||
let url = if inside.starts_with('/') { format!("File://{inside}") } else { format!("File:///{}", inside.replace('\\', "/")) };
|
||||
assert!(!super::unreadable_absolute(&url), "{url}");
|
||||
assert!(super::readable_value(&url), "{url}");
|
||||
let escaped = url.replacen("/in/a.svg", "/%69n/a.svg", 1);
|
||||
assert!(!super::unreadable_absolute(&escaped) && super::readable_value(&escaped), "{escaped}");
|
||||
assert!(super::unreadable_absolute("file:///outside%2Froot/x"));
|
||||
assert!(!super::unreadable_absolute(""));
|
||||
assert!(!super::unreadable_absolute("keep"));
|
||||
assert!(!super::unreadable_absolute(&inside));
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps
|
||||
/// it.
|
||||
#[test]
|
||||
fn a_url_naming_another_host_stays_without_a_read_root() {
|
||||
assert!(!super::unreadable_absolute("file://host/share/a.svg"));
|
||||
assert!(super::readable_value("file://host/share/a.svg"));
|
||||
let mut params = json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]});
|
||||
let fields = params.as_object_mut().unwrap();
|
||||
super::leave_out_unreadable_params(fields);
|
||||
assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}));
|
||||
}
|
||||
|
||||
/// A hidden path quoted with `{:?}` is hidden with its quotes and control characters escaped.
|
||||
#[test]
|
||||
fn a_reply_hides_a_quoted_path_with_escaped_quotes() {
|
||||
let hidden = hiding(&[("loaded//outside/a\"b\t.svg", "loaded/a\"b\t.svg")], &[]);
|
||||
let mut reply = json!({"error": format!("can't read {:?}", "/outside/a\"b\t.svg")});
|
||||
hide_under_a_root("quoted", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "can't read \"a\\\"b\\t.svg\"", "{reply}");
|
||||
}
|
||||
|
||||
/// Each replacement is chosen from the text as given: a hidden path inside the id shown for another
|
||||
/// hidden library is not replaced again, and a readable id written with `%XX` escapes keeps the hidden
|
||||
/// path it begins with.
|
||||
#[test]
|
||||
fn a_reply_replaces_each_hidden_text_once() {
|
||||
let hidden = hiding(&[("loaded//b.svg", "loaded/b.svg")], &[]);
|
||||
let mut reply = json!({"error": "no loaded//b.svg"});
|
||||
hide_under_a_root("root-file", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "no loaded/b.svg", "{reply}");
|
||||
let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg"]);
|
||||
let mut reply = json!({"error": "file:///art/in%20work/a.svg"});
|
||||
hide_under_a_root("escaped-readable", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}");
|
||||
// A URL whose scheme another hidden path took already is cut at its path.
|
||||
let hidden = hiding(&[("loaded//a", "loaded/a"), ("loaded/file", "loaded/file")], &[]);
|
||||
let mut reply = json!({"error": "file:///a"});
|
||||
hide_under_a_root("scheme-taken", &mut reply, &hidden);
|
||||
assert_eq!(reply["error"], "file://a", "{reply}");
|
||||
// Keys that come to read the same keep every field; a key that is not rewritten keeps its name.
|
||||
let hidden = hiding(&[("loaded//out/a.svg", "loaded/a.svg")], &[]);
|
||||
let mut reply = json!({"result": {"a.svg": 1, "file:///out/a.svg": 2, "/out/a.svg": 3}});
|
||||
hide_under_a_root("same-keys", &mut reply, &hidden);
|
||||
assert_eq!(reply["result"]["a.svg"], 1, "{reply}");
|
||||
let mut values: Vec<_> = reply["result"].as_object().map(|o| o.values().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
values.sort_unstable();
|
||||
assert_eq!(values, [1, 2, 3], "{reply}");
|
||||
}
|
||||
|
||||
/// A library saved with another platform's absolute path is hidden, also when the working folder is
|
||||
/// inside the read root.
|
||||
#[test]
|
||||
fn a_library_saved_with_another_platforms_path_is_hidden() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string();
|
||||
let here = std::env::current_dir().unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap();
|
||||
let hiding = vectorcraft_engine::file_access::confine(roots.as_ref(), || super::unreadable_library_ids(&app));
|
||||
assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}");
|
||||
}
|
||||
|
||||
/// Another platform's absolute path is outside the read root, also when the working folder is inside
|
||||
/// it: a request that sends one sees it by its file name.
|
||||
#[test]
|
||||
fn another_platforms_absolute_path_is_unreadable() {
|
||||
let here = std::env::current_dir().unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap();
|
||||
let path = r"C:\Users\someone\private.svg";
|
||||
let mut sent = Vec::new();
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
assert!(!super::readable(path) && super::unreadable_absolute(path));
|
||||
super::sent_paths(&json!({"list": [path]}), &mut sent);
|
||||
});
|
||||
assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]);
|
||||
}
|
||||
|
||||
/// A hidden relative path is hidden where it stands alone or after leading `./` and `../` parts, and
|
||||
/// stays where it ends a longer path, also one with a `.` or `..` part before it.
|
||||
#[test]
|
||||
fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() {
|
||||
let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]);
|
||||
let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"});
|
||||
hide_under_a_root("relative", &mut reply, &hidden);
|
||||
assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}");
|
||||
let mut reply = json!({"result": "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../data/icon.svg and './../data/icon.svg'"});
|
||||
hide_under_a_root("relative-dots", &mut reply, &hidden);
|
||||
assert_eq!(
|
||||
reply["result"], "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../icon.svg and './../icon.svg'",
|
||||
"{reply}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Outside Windows a `\` is part of a file name: a sent path outside the read root is shown by its
|
||||
/// whole file name.
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn a_sent_path_keeps_a_backslash_in_its_file_name() {
|
||||
let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
|
||||
let mut sent = Vec::new();
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent));
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]);
|
||||
}
|
||||
|
||||
/// `ui.inspect` and `ui.docking` show a hidden library whose file is at the top of the file system
|
||||
/// (`/b.svg`) as `loaded/b.svg`, with its separator.
|
||||
#[test]
|
||||
fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
let panel = "library:swatches:loaded//b.svg".to_string();
|
||||
app.ui.color_guide_limit = "loaded//b.svg".to_string();
|
||||
app.ui.open_panel = Some(panel);
|
||||
let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
|
||||
let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
(input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({})))
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}");
|
||||
assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}");
|
||||
assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}");
|
||||
}
|
||||
|
||||
/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded
|
||||
/// as in one that failed.
|
||||
#[test]
|
||||
fn a_sent_path_reads_the_same_whatever_the_outcome() {
|
||||
let app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
|
||||
let sent = json!({"path": "/outside/b.svg"});
|
||||
let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}});
|
||||
let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"});
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default());
|
||||
super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default());
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert_eq!(done["result"]["path"], "b.svg", "{done}");
|
||||
assert_eq!(failed["error"], "can't read b.svg", "{failed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
|
||||
+198
-45
@@ -6,9 +6,12 @@
|
||||
//! back. The Glyphs panel and Find Font only pick a font ([`picked`]): nothing is previewed on the
|
||||
//! document. A star marks a favourite family, and the ★ filter lists only those. The Font
|
||||
//! Technology filter's Variable lists only variable font families. Preferences › Type › Enable
|
||||
//! in-menu font previews and Font Preview Size turn the samples off or size the rows. Recently
|
||||
//! used families appear first, in the order they were last applied, within the same search and
|
||||
//! filters as the remaining families. In the Character and Properties panels and the Control bar,
|
||||
//! in-menu font previews and Font Preview Size turn the samples off or size the rows. The menu lays
|
||||
//! out its filters on the same four rows wherever it opens. However narrow its field, it opens at
|
||||
//! least wide enough for those rows and for a family name up to 220 points wide with its sample
|
||||
//! beside it, up to 60% of the window; each sample fills its row's height. Recently used families
|
||||
//! appear first, in the order they were last applied, within the same search and filters as the
|
||||
//! remaining families. In the Character and Properties panels and the Control bar,
|
||||
//! the mouse wheel turned over the closed field applies the next family (or previous one) to the
|
||||
//! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the
|
||||
//! pointer leaves the field or the text selection changes; steps stop at its first and last family.
|
||||
@@ -132,10 +135,29 @@ impl<'a> MenuLook<'a> {
|
||||
const ROW: f32 = 24.0;
|
||||
/// Width of the star at the end of a family's row.
|
||||
const STAR: f32 = 18.0;
|
||||
/// The share of a row the family's name takes; the sample fills the rest.
|
||||
/// The least share of a row the family's name takes when the row is too narrow for the widest
|
||||
/// name and [`SAMPLE_W`]; the sample fills the rest.
|
||||
const NAME_SHARE: f32 = 0.5;
|
||||
/// Size of a family's name in its row.
|
||||
const NAME_SIZE: f32 = 12.0;
|
||||
/// The widest name the menu widens itself for. A longer name takes more of a row only in a menu
|
||||
/// wider than that.
|
||||
pub(crate) const NAME_MAX: f32 = 220.0;
|
||||
/// Space between a family's name and its sample.
|
||||
const NAME_GAP: f32 = 8.0;
|
||||
/// How much wider than the sum of its characters' advances a name may lay out once shaped
|
||||
/// (kerning, ligatures, contextual forms), as a share of that sum.
|
||||
const SHAPING: f32 = 0.1;
|
||||
/// The room the menu keeps for a sample beside the widest name.
|
||||
const SAMPLE_W: f32 = 150.0;
|
||||
/// The greatest share of the window's width the menu takes.
|
||||
const WINDOW_SHARE: f32 = 0.6;
|
||||
/// Size of the filters' labels.
|
||||
const FILTER_SIZE: f32 = 11.0;
|
||||
/// Width of the disclosure triangle of a family with several styles.
|
||||
const DISCLOSURE: f32 = 14.0;
|
||||
/// How far a family's style rows are indented past the triangle.
|
||||
const STYLE_INDENT: f32 = 12.0;
|
||||
/// The list's greatest height, under the search field and filters.
|
||||
const LIST_HEIGHT: f32 = 600.0;
|
||||
/// Samples rendered (or asked for) per frame, across every menu, so a long list doesn't stall the UI.
|
||||
@@ -289,6 +311,8 @@ struct MenuState {
|
||||
rows_key: Option<RowsKey>,
|
||||
/// The height of the search field and filters last frame.
|
||||
header: f32,
|
||||
/// The width of the widest row of filters without wrapping, last frame.
|
||||
filters: f32,
|
||||
/// The families the wheel steps through, listed at its first notch over the closed field and
|
||||
/// kept for that text selection while the pointer stays on it: each step makes its family a recent one, which would
|
||||
/// otherwise move it in the order.
|
||||
@@ -321,7 +345,11 @@ pub fn font_menu(
|
||||
// of families.
|
||||
let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| {
|
||||
menu.height(height).show_rows_with_header(
|
||||
|ui| header(ui, state_id, state, current, look),
|
||||
|ui| {
|
||||
// Wider than a narrow field, so the filters keep their rows and long names show.
|
||||
ui.set_min_width(menu_width(ui, state, look));
|
||||
header(ui, state_id, state, current, look)
|
||||
},
|
||||
look.row,
|
||||
|h| h.rows.len(),
|
||||
|ui, range, h| rows(ui, range, h, sample, look),
|
||||
@@ -957,42 +985,7 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: &
|
||||
ui.memory_mut(|m| m.request_focus(field.with("edit")));
|
||||
}
|
||||
let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase();
|
||||
// The filters: script, then kind, then font technology.
|
||||
let mut refilter = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
for i in 0..SCRIPTS {
|
||||
if ui.selectable_label(st.script == i, egui::RichText::new(script_label(i)).size(11.0)).clicked() {
|
||||
st.script = i;
|
||||
refilter = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
if ui.selectable_label(st.favorites_only, egui::RichText::new("★").size(11.0)).on_hover_text(tl!("Favorites")).clicked() {
|
||||
st.favorites_only = !st.favorites_only;
|
||||
refilter = true;
|
||||
}
|
||||
if ui.selectable_label(st.class.is_none(), egui::RichText::new(tl!("Any Kind")).size(11.0)).clicked() {
|
||||
st.class = None;
|
||||
refilter = true;
|
||||
}
|
||||
for c in FontClass::ALL {
|
||||
if ui.selectable_label(st.class == Some(c), egui::RichText::new(class_label(c)).size(11.0)).clicked() {
|
||||
st.class = if st.class == Some(c) { None } else { Some(c) };
|
||||
refilter = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
ui.label(egui::RichText::new(tl!("Font Technology")).size(11.0).color(t.text_dim));
|
||||
if ui.selectable_label(st.variable_only, egui::RichText::new(variable_label()).size(11.0)).clicked() {
|
||||
st.variable_only = !st.variable_only;
|
||||
refilter = true;
|
||||
}
|
||||
});
|
||||
let refilter = filters(ui, st, &t);
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック),
|
||||
@@ -1064,6 +1057,149 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: &
|
||||
Listed { st, rows, current: current.to_string(), enter, keyed, opening }
|
||||
}
|
||||
|
||||
/// One of the filters' chips.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Chip {
|
||||
/// A script ([`SCRIPTS`]).
|
||||
Script(usize),
|
||||
Favorites,
|
||||
AnyKind,
|
||||
Class(FontClass),
|
||||
/// The Font Technology label, which filters nothing.
|
||||
Technology,
|
||||
Variable,
|
||||
}
|
||||
|
||||
/// The filters, row by row, the same wherever the menu opens: the scripts; favorites, any kind
|
||||
/// and the first three kinds; the other kinds; font technology.
|
||||
fn filter_rows() -> [Vec<Chip>; 4] {
|
||||
let (first, rest) = FontClass::ALL.split_at(3);
|
||||
[
|
||||
(0..SCRIPTS).map(Chip::Script).collect(),
|
||||
[Chip::Favorites, Chip::AnyKind].into_iter().chain(first.iter().copied().map(Chip::Class)).collect(),
|
||||
rest.iter().copied().map(Chip::Class).collect(),
|
||||
vec![Chip::Technology, Chip::Variable],
|
||||
]
|
||||
}
|
||||
|
||||
/// The filters on their rows ([`filter_rows`]): a row wraps only where the menu is narrower than
|
||||
/// it. Records in `st` the width of the widest row unwrapped; whether a filter changed.
|
||||
fn filters(ui: &mut Ui, st: &mut MenuState, t: &Tokens) -> bool {
|
||||
let mut refilter = false;
|
||||
let mut widest = 0.0_f32;
|
||||
for row in filter_rows() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let text = |s: &str| egui::RichText::new(s).size(FILTER_SIZE);
|
||||
let mut width = -gap;
|
||||
for chip in row {
|
||||
let resp = match chip {
|
||||
Chip::Script(i) => ui.selectable_label(st.script == i, text(script_label(i))),
|
||||
Chip::Favorites => ui.selectable_label(st.favorites_only, text("★")).on_hover_text(tl!("Favorites")),
|
||||
Chip::AnyKind => ui.selectable_label(st.class.is_none(), text(tl!("Any Kind"))),
|
||||
Chip::Class(c) => ui.selectable_label(st.class == Some(c), text(class_label(c))),
|
||||
Chip::Technology => ui.label(text(tl!("Font Technology")).color(t.text_dim)),
|
||||
Chip::Variable => ui.selectable_label(st.variable_only, text(variable_label())),
|
||||
};
|
||||
width += resp.rect.width() + gap;
|
||||
if !resp.clicked() {
|
||||
continue;
|
||||
}
|
||||
match chip {
|
||||
Chip::Script(i) => st.script = i,
|
||||
Chip::Favorites => st.favorites_only = !st.favorites_only,
|
||||
Chip::AnyKind => st.class = None,
|
||||
Chip::Class(c) => st.class = if st.class == Some(c) { None } else { Some(c) },
|
||||
Chip::Technology => continue,
|
||||
Chip::Variable => st.variable_only = !st.variable_only,
|
||||
}
|
||||
refilter = true;
|
||||
}
|
||||
widest = widest.max(width);
|
||||
});
|
||||
}
|
||||
st.filters = widest;
|
||||
refilter
|
||||
}
|
||||
|
||||
/// The width the open menu takes at least: the widest row of filters last frame, and the widest
|
||||
/// name up to [`NAME_MAX`] with room for its sample, the star and the scroll bar beside it, within
|
||||
/// [`WINDOW_SHARE`] of the window. The menu is never narrower than its field.
|
||||
fn menu_width(ui: &Ui, st: &MenuState, look: MenuLook) -> f32 {
|
||||
let sample = if look.samples { SAMPLE_W } else { 0.0 };
|
||||
let names = name_width(ui, look.english_names, &st.rows).min(NAME_MAX);
|
||||
let row = DISCLOSURE + names + NAME_GAP + sample + STAR + 2.0 + widgets::list_scroll_style().allocated_width();
|
||||
// Rounding in the chips' widths must not wrap the widest row.
|
||||
let filters = if st.filters > 0.0 { st.filters.ceil() + 1.0 } else { 0.0 };
|
||||
filters.max(row).min(ui.ctx().content_rect().width() * WINDOW_SHARE)
|
||||
}
|
||||
|
||||
/// The width of the widest name the menu lists after the disclosure triangle: the family names as
|
||||
/// Show Font Names in English shows them, and the style names among `rows` after their indent.
|
||||
fn name_width(ui: &Ui, english: bool, rows: &[Row]) -> f32 {
|
||||
let font = egui::FontId::proportional(NAME_SIZE);
|
||||
let measure = |name: &str| ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x;
|
||||
let styles = rows.iter().filter_map(Row::style).map(|s| STYLE_INDENT + measure(s)).fold(0.0, f32::max);
|
||||
family_name_width(ui, english).max(styles)
|
||||
}
|
||||
|
||||
/// What the widest family name was measured for: the font list's generation, Show Font Names in
|
||||
/// English, and the number of fonts the interface draws with, which grows as faces for other
|
||||
/// scripts are added to it.
|
||||
fn family_name_key(ui: &Ui, english: bool) -> (u64, bool, usize) {
|
||||
let faces = ui.ctx().fonts(|f| f.definitions().font_data.len());
|
||||
(vectorcraft_text::FontDb::global().generation(), english, faces)
|
||||
}
|
||||
|
||||
/// The id under which the widest family name's width is kept with its [`family_name_key`].
|
||||
fn family_name_id() -> egui::Id {
|
||||
egui::Id::unique("font-menu-name-width")
|
||||
}
|
||||
|
||||
/// The width of the widest family name the menu lists, labeled as Show Font Names in English
|
||||
/// (`english`) labels it; worked out again only when its [`family_name_key`] changes.
|
||||
fn family_name_width(ui: &Ui, english: bool) -> f32 {
|
||||
let key = family_name_key(ui, english);
|
||||
if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, bool, usize), f32)>(family_name_id()))
|
||||
&& k == key
|
||||
{
|
||||
return w;
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let w = widest_label(ui, db.menu_family_list().iter().map(|f| db.family_display_name(f, english)).collect());
|
||||
ui.data_mut(|d| d.insert_temp(family_name_id(), (key, w)));
|
||||
w
|
||||
}
|
||||
|
||||
/// The width of the widest of `labels` laid out at the names' size. Each label's characters'
|
||||
/// advances are added up, and the labels are laid out widest sum first, until no sum left could
|
||||
/// reach the widest layout, allowing [`SHAPING`] for shaping.
|
||||
fn widest_label(ui: &Ui, labels: Vec<String>) -> f32 {
|
||||
let font = egui::FontId::proportional(NAME_SIZE);
|
||||
let mut sums: Vec<(f32, String)> =
|
||||
ui.ctx().fonts_mut(|f| labels.into_iter().map(|l| (l.chars().map(|c| f.glyph_width(&font, c)).sum(), l)).collect());
|
||||
sums.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut w = 0.0_f32;
|
||||
for (sum, label) in sums {
|
||||
if sum * (1.0 + SHAPING) + 1.0 <= w {
|
||||
break;
|
||||
}
|
||||
w = w.max(ui.painter().layout_no_wrap(label, font.clone(), Color32::WHITE).size().x);
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Where a row `width` wide puts its sample, after the name column: room for the widest name
|
||||
/// (`widest`) and a gap, as far as that leaves [`SAMPLE_W`] and the star for the sample or
|
||||
/// [`NAME_SHARE`] of the row for the name. Without samples the name runs to the star.
|
||||
fn name_column(width: f32, widest: f32, samples: bool) -> f32 {
|
||||
if !samples {
|
||||
return width - STAR;
|
||||
}
|
||||
(DISCLOSURE + widest + NAME_GAP).min((width - STAR - 2.0 - SAMPLE_W).max(width * NAME_SHARE))
|
||||
}
|
||||
|
||||
/// The disclosure triangle of a family with several styles; whether the pointer is on it.
|
||||
fn disclosure(p: &egui::Painter, resp: &egui::Response, tri: egui::Rect, open: bool, t: &Tokens) -> bool {
|
||||
p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim);
|
||||
@@ -1101,6 +1237,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f));
|
||||
let widest = name_width(ui, look.english_names, &rows);
|
||||
if rows.is_empty() {
|
||||
// A filter or a font rescan can remove the row whose font was previewed.
|
||||
// Do not leave that font applied while the list has no candidate to highlight.
|
||||
@@ -1166,8 +1303,8 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio
|
||||
starred = Some(f.clone());
|
||||
}
|
||||
}
|
||||
let indent = if row.style().is_some() { DISCLOSURE + 12.0 } else { DISCLOSURE };
|
||||
let name_w = rect.width() * NAME_SHARE;
|
||||
let indent = if row.style().is_some() { DISCLOSURE + STYLE_INDENT } else { DISCLOSURE };
|
||||
let name_w = name_column(rect.width(), widest, look.samples);
|
||||
let name_rect = egui::Rect::from_min_max(rect.min + vec2(indent, 0.0), egui::pos2(rect.min.x + name_w - 4.0, rect.max.y));
|
||||
// Family rows follow Show Font Names in English; style rows keep the style name (#394).
|
||||
let name = match row {
|
||||
@@ -1175,8 +1312,9 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio
|
||||
Row::Family(f) => db.family_display_name(f, look.english_names),
|
||||
Row::Heading(_) => continue,
|
||||
};
|
||||
ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text);
|
||||
let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 2.0), rect.max - vec2(STAR + 2.0, 2.0));
|
||||
ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(NAME_SIZE), t.text);
|
||||
// The sample takes the row's whole height.
|
||||
let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 0.0), rect.max - vec2(STAR + 2.0, 0.0));
|
||||
if !look.samples {
|
||||
} else if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, sample_rect.height()) {
|
||||
paint_sample(ui, sample_rect, &tex, t.text);
|
||||
@@ -1376,6 +1514,21 @@ pub(crate) fn render_sample_for_test(family: &str, text: &str, px: u32) -> Optio
|
||||
render_sample(family, "Regular", text, px)
|
||||
}
|
||||
|
||||
/// Waits for the samples rendering on this thread's behalf and keeps them, so the next frame
|
||||
/// draws them.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn wait_for_samples(ctx: &egui::Context) {
|
||||
SAMPLES.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
let pending: Vec<_> = s.pending.drain().collect();
|
||||
for (key, rx) in pending {
|
||||
let tex =
|
||||
rx.recv().ok().flatten().map(|img| ctx.load_texture(format!("font-sample-{}-{}", key.0, key.3), img, egui::TextureOptions::LINEAR));
|
||||
s.ready.insert(key, tex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- applying a pick ----------
|
||||
|
||||
/// Is a font previewed on the text (an interaction is open for it)?
|
||||
|
||||
@@ -58,6 +58,103 @@ fn the_scroll_bar_leaves_the_stars_clear() {
|
||||
}
|
||||
}
|
||||
|
||||
/// In a wide row the sample starts after the widest name and a gap, not halfway along, even for a
|
||||
/// name wider than the menu widens itself for; the sample keeps its room, and a row too narrow for
|
||||
/// both gives the name half of it. Without samples the name runs to the star.
|
||||
#[test]
|
||||
fn the_sample_starts_after_the_widest_name() {
|
||||
use super::{DISCLOSURE, NAME_GAP, NAME_MAX, SAMPLE_W, STAR, name_column};
|
||||
assert_eq!(name_column(500.0, 100.0, true), DISCLOSURE + 100.0 + NAME_GAP);
|
||||
assert_eq!(name_column(600.0, NAME_MAX + 80.0, true), DISCLOSURE + NAME_MAX + 80.0 + NAME_GAP);
|
||||
assert_eq!(name_column(600.0, 1000.0, true), 600.0 - STAR - 2.0 - SAMPLE_W);
|
||||
assert_eq!(name_column(200.0, 100.0, true), 100.0);
|
||||
assert_eq!(name_column(500.0, 100.0, false), 500.0 - STAR);
|
||||
}
|
||||
|
||||
/// The widest label is the widest one laid out, though it has fewer characters than others, or
|
||||
/// characters that are narrow on their own.
|
||||
#[test]
|
||||
fn the_widest_label_is_the_widest_laid_out() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut labels: Vec<String> = ["WWWWWWWW", "Mm", "", "ffi ffl fi fl", "Source Serif 4 Display Semibold"].map(str::to_string).into();
|
||||
labels.push("i".repeat(40));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let font = egui::FontId::proportional(super::NAME_SIZE);
|
||||
let laid_out = |l: &String| ui.painter().layout_no_wrap(l.clone(), font.clone(), egui::Color32::WHITE).size().x;
|
||||
let widest = labels.iter().map(laid_out).fold(0.0, f32::max);
|
||||
assert_eq!(super::widest_label(ui, labels.clone()), widest);
|
||||
for l in &labels {
|
||||
assert_eq!(super::widest_label(ui, vec![l.clone()]), laid_out(l), "{l}");
|
||||
}
|
||||
assert_eq!(super::widest_label(ui, vec![]), 0.0);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The widest family name is measured again when the interface's fonts change, as they do when
|
||||
/// faces for another script are added to it, not only when the font list does.
|
||||
#[test]
|
||||
fn the_widest_name_is_measured_again_when_the_interface_fonts_change() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let frame = |f: &mut dyn FnMut(&mut egui::Ui)| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
// A width no name has, kept as if measured for the fonts as they are.
|
||||
frame(&mut |ui| {
|
||||
let key = super::family_name_key(ui, true);
|
||||
ui.data_mut(|d| d.insert_temp(super::family_name_id(), (key, -1.0_f32)));
|
||||
});
|
||||
frame(&mut |ui| {
|
||||
let data = ui.ctx().fonts(|f| f.definitions().font_data.values().next().cloned()).expect("an interface font");
|
||||
ui.ctx().add_font(egui::epaint::text::FontInsert { name: "font-menu-test-face".into(), data: (*data).clone(), families: vec![] });
|
||||
});
|
||||
frame(&mut |ui| assert!(super::family_name_width(ui, true) >= 0.0, "measured again"));
|
||||
}
|
||||
|
||||
/// Without a row of filters drawn yet, the menu still makes room for the widest name, its sample,
|
||||
/// the star and the scroll bar; without samples it is the sample's room narrower.
|
||||
#[test]
|
||||
fn the_menu_makes_room_for_the_widest_name_and_its_sample() {
|
||||
use super::{DISCLOSURE, NAME_GAP, NAME_MAX, SAMPLE_W, STAR};
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 900.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
let st = super::MenuState::default();
|
||||
let look = super::MenuLook::default();
|
||||
let names = super::name_width(ui, look.english_names, &st.rows).min(NAME_MAX);
|
||||
assert!(names > 0.0, "a family is listed");
|
||||
let width = super::menu_width(ui, &st, look);
|
||||
assert!(width >= DISCLOSURE + names + NAME_GAP + SAMPLE_W + STAR, "the menu is {width} wide for a name {names} wide");
|
||||
let plain = super::menu_width(ui, &st, super::MenuLook { samples: false, ..look });
|
||||
assert!((width - plain - SAMPLE_W).abs() < 0.01, "{width} with samples, {plain} without");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The style names of the families expanded in the listed rows count toward the names' width;
|
||||
/// those of a family expanded earlier but not listed now do not.
|
||||
#[test]
|
||||
fn only_listed_style_names_widen_the_names() {
|
||||
use super::{Row, STYLE_INDENT};
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let long = format!("SemiCondensed ExtraLight Italic {}", "W".repeat(80));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let families = super::name_width(ui, true, &[]);
|
||||
let font = egui::FontId::proportional(super::NAME_SIZE);
|
||||
let style = STYLE_INDENT + ui.painter().layout_no_wrap(long.clone(), font, egui::Color32::WHITE).size().x;
|
||||
assert!(style > families, "the style name is wider than every family name");
|
||||
let listed = [Row::Family("A".into()), Row::Style("A".into(), long.clone())];
|
||||
assert_eq!(super::name_width(ui, true, &listed), style);
|
||||
assert!(super::name_width(ui, true, &listed[..1]) < style, "a style not listed is not measured");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
use super::{Row, remap_highlight};
|
||||
|
||||
fn family(name: &str) -> Row {
|
||||
|
||||
@@ -2004,6 +2004,7 @@ font-technology Variable Променливи
|
||||
Move Slices Преместване на резени
|
||||
Move Spine Point Преместване на точка от оста
|
||||
Reshape Spine Segment Промяна на формата на сегмент от оста
|
||||
Blend Easing Плавност на преливането
|
||||
Move Brush Преместване на четка
|
||||
Move Swatch Преместване на образец
|
||||
Move Up Преместване нагоре
|
||||
@@ -6069,6 +6070,11 @@ axis Y Axis Y ос
|
||||
User Brush Library 9 Потребителска библиотека с четки 9
|
||||
User Brush Library 10 Потребителска библиотека с четки 10
|
||||
Art Ribbons Арт ленти
|
||||
Ink and Wash Туш и лавиране
|
||||
Tapered Ink Изтъняващ туш
|
||||
Brush Pen Писалка-четка
|
||||
Soft Wash Меко лавиране
|
||||
Pooling Wash Лавиране с ръб
|
||||
Zigzag Зигзаг
|
||||
Burst Ray Лъч на удар
|
||||
Dash Trail Пунктирна следа
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3556,6 +3556,7 @@ theme Appearance Izgled
|
||||
Move Mesh Points Pomjeri tačke mreže
|
||||
Move Spine Point Pomjeri tačku kičme
|
||||
Reshape Spine Segment Preoblikuj segment kičme
|
||||
Blend Easing Ublažavanje stapanja
|
||||
Reset with Mesh Vrati mrežom
|
||||
Reset with Warp Vrati iskrivljenjem
|
||||
Set Mesh Point Color Postavi boju tačke mreže
|
||||
@@ -6122,6 +6123,11 @@ axis Y Axis Y osa
|
||||
User Brush Library 9 Korisnička biblioteka četkica 9
|
||||
User Brush Library 10 Korisnička biblioteka četkica 10
|
||||
Art Ribbons Umjetničke trake
|
||||
Ink and Wash Tuš i lavir
|
||||
Tapered Ink Suženi tuš
|
||||
Brush Pen Kistasti flomaster
|
||||
Soft Wash Meki lavir
|
||||
Pooling Wash Lavir s rubom
|
||||
Zigzag Cik-cak
|
||||
Burst Ray Zraka udara
|
||||
Dash Trail Isprekidani trag
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -363,6 +363,11 @@ antiAlias Sharp Nítid
|
||||
Art Brush Options Opcions del pinzell artístic
|
||||
Art Optimized Optimitzat per a l’art
|
||||
Art Ribbons Cintes d’art
|
||||
Ink and Wash Tinta i aiguada
|
||||
Tapered Ink Tinta afuada
|
||||
Brush Pen Retolador de pinzell
|
||||
Soft Wash Aiguada suau
|
||||
Pooling Wash Aiguada amb vora
|
||||
Zigzag Zigzag
|
||||
Burst Ray Raig d’impacte
|
||||
Dash Trail Estela discontínua
|
||||
@@ -3604,6 +3609,7 @@ digits Malayalam Malaiàlam
|
||||
Reshape Segment Remodela el segment
|
||||
Reshape Spine Remodela l’espina
|
||||
Reshape Spine Segment Remodela el segment de l’espina
|
||||
Blend Easing Suavitzat de la fusió
|
||||
Reshape Tool Eina de remodelació
|
||||
Reshape Type Area Remodela l’àrea de text
|
||||
Resize Redimensiona
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3610,6 +3610,7 @@ theme Appearance Motiv
|
||||
Move Mesh Points Přesunout body sítě
|
||||
Move Spine Point Přesunout bod páteře
|
||||
Reshape Spine Segment Přetvarovat segment páteře
|
||||
Blend Easing Náběh prolnutí
|
||||
Reset with Mesh Obnovit sítí
|
||||
Reset with Warp Obnovit pokřivením
|
||||
Set Mesh Point Color Nastavit barvu bodu sítě
|
||||
@@ -6337,6 +6338,11 @@ axis Y Axis osa Y
|
||||
User Brush Library 9 Uživatelská knihovna štětců 9
|
||||
User Brush Library 10 Uživatelská knihovna štětců 10
|
||||
Art Ribbons Obrazové stuhy
|
||||
Ink and Wash Tuš a lavírování
|
||||
Tapered Ink Zužující se tuš
|
||||
Brush Pen Štětcový fix
|
||||
Soft Wash Jemná lavírka
|
||||
Pooling Wash Lavírka s okrajem
|
||||
Zigzag Cikcak
|
||||
Burst Ray Paprsek nárazu
|
||||
Dash Trail Čárkovaná stopa
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3343,6 +3343,7 @@ theme Appearance Udseende
|
||||
Move Mesh Points Flyt meshpunkter
|
||||
Move Spine Point Flyt rygradpunkt
|
||||
Reshape Spine Segment Omform rygradsegment
|
||||
Blend Easing Blandingens acceleration
|
||||
Reset with Mesh Nulstil med mesh
|
||||
Reset with Warp Nulstil med forvrængning
|
||||
Set Mesh Point Color Angiv farve for meshpunkt
|
||||
@@ -6086,6 +6087,11 @@ axis Y Axis Y-akse
|
||||
User Brush Library 9 Brugerpenselbibliotek 9
|
||||
User Brush Library 10 Brugerpenselbibliotek 10
|
||||
Art Ribbons Kunstbånd
|
||||
Ink and Wash Blæk og lavering
|
||||
Tapered Ink Tilspidset blæk
|
||||
Brush Pen Penselpen
|
||||
Soft Wash Blød lavering
|
||||
Pooling Wash Lavering med rand
|
||||
Zigzag Zigzag
|
||||
Burst Ray Burststråle
|
||||
Dash Trail Streget spor
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2080,6 +2080,7 @@ file-filter Templates Vorlagen
|
||||
Move Slices Slices verschieben
|
||||
Move Spine Point Achsenpunkt verschieben
|
||||
Reshape Spine Segment Achsensegment umformen
|
||||
Blend Easing Beschleunigung der Angleichung
|
||||
Move Brush Pinsel verschieben
|
||||
Move Swatch Farbfeld verschieben
|
||||
Move Up Nach oben verschieben
|
||||
@@ -5928,6 +5929,11 @@ axis Y Axis Y-Achse
|
||||
User Brush Library 9 Benutzer-Pinsel-Bibliothek 9
|
||||
User Brush Library 10 Benutzer-Pinsel-Bibliothek 10
|
||||
Art Ribbons Bildbänder
|
||||
Ink and Wash Tusche und Lavur
|
||||
Tapered Ink Spitz zulaufende Tusche
|
||||
Brush Pen Pinselstift
|
||||
Soft Wash Weiche Lavur
|
||||
Pooling Wash Lavur mit Farbrand
|
||||
Zigzag Zickzack
|
||||
Burst Ray Stoßstrahl
|
||||
Dash Trail Strichelspur
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2916,6 +2916,7 @@ theme Appearance Εμφάνιση
|
||||
Move Slices Μετακίνηση τομών
|
||||
Move Spine Point Μετακίνηση σημείου κεντρικής καμπύλης
|
||||
Reshape Spine Segment Αναδιαμόρφωση τμήματος κεντρικής καμπύλης
|
||||
Blend Easing Επιτάχυνση ανάμειξης
|
||||
Move Brush Μετακίνηση πινέλου
|
||||
Move Swatch Μετακίνηση δείγματος χρώματος
|
||||
Move Up Μετακίνηση επάνω
|
||||
@@ -6288,6 +6289,11 @@ axis Y Axis Άξονας Y
|
||||
User Brush Library 9 Βιβλιοθήκη πινέλων χρήστη 9
|
||||
User Brush Library 10 Βιβλιοθήκη πινέλων χρήστη 10
|
||||
Art Ribbons Γραφικές κορδέλες
|
||||
Ink and Wash Μελάνι και ακουαρέλα
|
||||
Tapered Ink Λεπτυνόμενο μελάνι
|
||||
Brush Pen Πένα πινέλου
|
||||
Soft Wash Απαλή ακουαρέλα
|
||||
Pooling Wash Ακουαρέλα με περίγραμμα
|
||||
Zigzag Ζιγκζάγκ
|
||||
Burst Ray Ακτίνα κρούσης
|
||||
Dash Trail Διακεκομμένο ίχνος
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2074,6 +2074,7 @@ font-technology Variable Variables
|
||||
Move Slices Mover sectores
|
||||
Move Spine Point Mover punto del eje
|
||||
Reshape Spine Segment Remodelar segmento del eje
|
||||
Blend Easing Suavizado de fusión
|
||||
Move Brush Mover pincel
|
||||
Move Swatch Mover muestra
|
||||
Move Up Subir
|
||||
@@ -6279,6 +6280,11 @@ axis Y Axis Eje Y
|
||||
User Brush Library 9 Biblioteca de pinceles del usuario 9
|
||||
User Brush Library 10 Biblioteca de pinceles del usuario 10
|
||||
Art Ribbons Cintas de ilustración
|
||||
Ink and Wash Tinta y aguada
|
||||
Tapered Ink Tinta afilada
|
||||
Brush Pen Rotulador de pincel
|
||||
Soft Wash Aguada suave
|
||||
Pooling Wash Aguada con borde
|
||||
Zigzag Zigzag
|
||||
Burst Ray Rayo de impacto
|
||||
Dash Trail Estela discontinua
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3548,6 +3548,7 @@ theme Appearance Ulkoasu
|
||||
Move Mesh Points Siirrä verkkopisteitä
|
||||
Move Spine Point Siirrä selkärangan pistettä
|
||||
Reshape Spine Segment Muotoile selkärangan segmentti uudelleen
|
||||
Blend Easing Sulautuksen pehmennys
|
||||
Reset with Mesh Palauta verkolla
|
||||
Reset with Warp Palauta vääristyksellä
|
||||
Set Mesh Point Color Aseta verkkopisteen väri
|
||||
@@ -6367,6 +6368,11 @@ axis Y Axis Y-akseli
|
||||
User Brush Library 9 Käyttäjän sivellinkirjasto 9
|
||||
User Brush Library 10 Käyttäjän sivellinkirjasto 10
|
||||
Art Ribbons Kuvitusnauhat
|
||||
Ink and Wash Muste ja laveeraus
|
||||
Tapered Ink Suippeneva muste
|
||||
Brush Pen Pensselikynä
|
||||
Soft Wash Pehmeä laveeraus
|
||||
Pooling Wash Reunustettu laveeraus
|
||||
Zigzag Siksak
|
||||
Burst Ray Iskusäde
|
||||
Dash Trail Katkoviiva
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2068,6 +2068,7 @@ font-technology Variable Variables
|
||||
Move Slices Déplacer les tranches
|
||||
Move Spine Point Déplacer le point de la dorsale
|
||||
Reshape Spine Segment Modeler le segment de la dorsale
|
||||
Blend Easing Lissage du dégradé de formes
|
||||
Move Brush Déplacer la forme
|
||||
Move Swatch Déplacer la nuance
|
||||
Move Up Monter
|
||||
@@ -6332,6 +6333,11 @@ axis Y Axis Axe Y
|
||||
User Brush Library 9 Bibliothèque de formes utilisateur 9
|
||||
User Brush Library 10 Bibliothèque de formes utilisateur 10
|
||||
Art Ribbons Rubans d'illustration
|
||||
Ink and Wash Encre et lavis
|
||||
Tapered Ink Encre effilée
|
||||
Brush Pen Feutre pinceau
|
||||
Soft Wash Lavis doux
|
||||
Pooling Wash Lavis à auréole
|
||||
Zigzag Zigzag
|
||||
Burst Ray Rayon d’impact
|
||||
Dash Trail Traînée tiretée
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -424,6 +424,11 @@ antiAlias Sharp शार्प
|
||||
Art Brush Options आर्ट ब्रश ऑप्शन्स
|
||||
Art Optimized आर्टवर्क के लिए अनुकूलित
|
||||
Art Ribbons आर्ट रिबन
|
||||
Ink and Wash स्याही और वॉश
|
||||
Tapered Ink पतली होती स्याही
|
||||
Brush Pen ब्रश पेन
|
||||
Soft Wash सॉफ्ट वॉश
|
||||
Pooling Wash पूलिंग वॉश
|
||||
Zigzag ज़िगज़ैग
|
||||
Burst Ray बर्स्ट रे
|
||||
Dash Trail डैश ट्रेल
|
||||
@@ -3936,6 +3941,7 @@ antiAlias Sharp शार्प
|
||||
Reshape Segment सेगमेंट को आकार देना
|
||||
Reshape Spine स्पाइन का आकार बदलें
|
||||
Reshape Spine Segment स्पिन सेगमेंट को फिर से आकार दें
|
||||
Blend Easing ब्लेंड ईज़िंग
|
||||
Reshape Tool रीशेप टूल
|
||||
Reshape Type Area रीशैप टाइप एरिया
|
||||
Reshape Type Path टाइप पाथ रीशेप करें
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3458,6 +3458,7 @@ mark-style Japanese Japán
|
||||
Reshape Átformálás
|
||||
Reshape Segment Szakasz átformálása
|
||||
Reshape Spine Segment Gerincszakasz átformálása
|
||||
Blend Easing Összemosás gyorsítása
|
||||
Reshape Tool Átformálás
|
||||
Reshape Type Area Szövegterület átformálása
|
||||
Resize Átméretezés
|
||||
@@ -6320,6 +6321,11 @@ axis Y Axis Y tengely
|
||||
User Brush Library 9 Felhasználói ecsetkönyvtár 9
|
||||
User Brush Library 10 Felhasználói ecsetkönyvtár 10
|
||||
Art Ribbons Grafikus szalagok
|
||||
Ink and Wash Tus és lavírozás
|
||||
Tapered Ink Elvékonyodó tus
|
||||
Brush Pen Ecsetfilc
|
||||
Soft Wash Lágy lavírozás
|
||||
Pooling Wash Szegélyes lavírozás
|
||||
Zigzag Cikkcakk
|
||||
Burst Ray Ütéssugár
|
||||
Dash Trail Szaggatott nyom
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3343,6 +3343,7 @@ theme Appearance Tampilan
|
||||
Move Mesh Points Pindahkan titik-titik mesh
|
||||
Move Spine Point Pindahkan titik spine
|
||||
Reshape Spine Segment Bentuk ulang segmen spine
|
||||
Blend Easing Easing blend
|
||||
Reset with Mesh Atur ulang dengan mesh
|
||||
Reset with Warp Atur ulang dengan warp
|
||||
Set Mesh Point Color Tetapkan warna titik mesh
|
||||
@@ -6086,6 +6087,11 @@ axis Y Axis Sumbu Y
|
||||
User Brush Library 9 Pustaka kuas pengguna 9
|
||||
User Brush Library 10 Pustaka kuas pengguna 10
|
||||
Art Ribbons Pita seni
|
||||
Ink and Wash Tinta dan sapuan
|
||||
Tapered Ink Tinta meruncing
|
||||
Brush Pen Pena kuas
|
||||
Soft Wash Sapuan lembut
|
||||
Pooling Wash Sapuan menggenang
|
||||
Zigzag Zigzag
|
||||
Burst Ray Sinar ledakan
|
||||
Dash Trail Jejak putus-putus
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2402 and column 5.
|
@@ -2112,6 +2112,7 @@ file-filter Templates Modelli
|
||||
Move Slices Sposta sezioni
|
||||
Move Spine Point Sposta punto dorsale
|
||||
Reshape Spine Segment Rimodella segmento della dorsale
|
||||
Blend Easing Andamento fusione
|
||||
Move Brush Sposta pennello
|
||||
Move Swatch Sposta campione
|
||||
Move Up Sposta su
|
||||
@@ -5962,6 +5963,11 @@ axis Y Axis Asse Y
|
||||
User Brush Library 9 Libreria pennelli utente 9
|
||||
User Brush Library 10 Libreria pennelli utente 10
|
||||
Art Ribbons Nastri grafici
|
||||
Ink and Wash Inchiostro e acquerellato
|
||||
Tapered Ink Inchiostro affusolato
|
||||
Brush Pen Pennarello a pennello
|
||||
Soft Wash Velatura morbida
|
||||
Pooling Wash Velatura con bordo
|
||||
Zigzag Zigzag
|
||||
Burst Ray Raggio d’impatto
|
||||
Dash Trail Scia tratteggiata
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2328,6 +2328,7 @@ antiAlias Sharp シャープ
|
||||
Move Slices スライスを移動
|
||||
Move Spine Point スパインポイントを移動
|
||||
Reshape Spine Segment スパインセグメントをシェイプ変形
|
||||
Blend Easing ブレンドのイージング
|
||||
Move Brush ブラシを移動
|
||||
Move Swatch スウォッチを移動
|
||||
Move Up 上へ移動
|
||||
@@ -6027,6 +6028,11 @@ axis Y Axis Y 軸
|
||||
User Brush Library 9 ユーザーブラシライブラリ 9
|
||||
User Brush Library 10 ユーザーブラシライブラリ 10
|
||||
Art Ribbons アートリボン
|
||||
Ink and Wash 墨と淡彩
|
||||
Tapered Ink 先細りの墨
|
||||
Brush Pen 筆ペン
|
||||
Soft Wash 柔らかい淡彩
|
||||
Pooling Wash たまりのある淡彩
|
||||
Zigzag ジグザグ
|
||||
Burst Ray バーストレイ
|
||||
Dash Trail ダッシュトレイル
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3189,6 +3189,7 @@ theme Appearance იერსახე
|
||||
Move Mesh Points ბადის წერტილების გადაადგილება
|
||||
Move Spine Point ხერხემლის წერტილის გადაადგილება
|
||||
Reshape Spine Segment ხერხემლის სეგმენტის ფორმის შეცვლა
|
||||
Blend Easing გადასვლის შერბილება
|
||||
Reset with Mesh ბადით ჩამოყრა
|
||||
Reset with Warp გაღუნვით ხელახლა დაყენება
|
||||
Set Mesh Point Color ბადის წერტილის ფერის დაყენება
|
||||
@@ -6288,6 +6289,11 @@ axis Y Axis Y ღერძი
|
||||
User Brush Library 9 მომხმარებლის ფუნჯების ბიბლიოთეკა 9
|
||||
User Brush Library 10 მომხმარებლის ფუნჯების ბიბლიოთეკა 10
|
||||
Art Ribbons ნახატის ლენტები
|
||||
Ink and Wash მელანი და ლავირება
|
||||
Tapered Ink წვეტიანი მელანი
|
||||
Brush Pen ფუნჯ-კალამი
|
||||
Soft Wash რბილი ლავირება
|
||||
Pooling Wash კიდიანი ლავირება
|
||||
Zigzag ზიგზაგი
|
||||
Burst Ray დარტყმის სხივი
|
||||
Dash Trail წყვეტილი კვალი
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2318,6 +2318,7 @@ antiAlias Sharp 날카롭게
|
||||
Move Slices 슬라이스 이동
|
||||
Move Spine Point 스파인 포인트 이동
|
||||
Reshape Spine Segment 스파인 세그먼트 모양 변경
|
||||
Blend Easing 블렌드 이징
|
||||
Move Brush 브러시 이동
|
||||
Move Swatch 견본 이동
|
||||
Move Up 위로 이동
|
||||
@@ -5923,6 +5924,11 @@ axis Y Axis Y축
|
||||
User Brush Library 9 사용자 브러시 라이브러리 9
|
||||
User Brush Library 10 사용자 브러시 라이브러리 10
|
||||
Art Ribbons 아트 리본
|
||||
Ink and Wash 먹과 담채
|
||||
Tapered Ink 가늘어지는 먹선
|
||||
Brush Pen 붓펜
|
||||
Soft Wash 부드러운 담채
|
||||
Pooling Wash 고임 담채
|
||||
Zigzag 지그재그
|
||||
Burst Ray 버스트 레이
|
||||
Dash Trail 대시 트레일
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3357,6 +3357,7 @@ theme Appearance Vormgeving
|
||||
Move Mesh Points Maaspunten verplaatsen
|
||||
Move Spine Point Aspunt verplaatsen
|
||||
Reshape Spine Segment Segment van de as opnieuw vormgeven
|
||||
Blend Easing Blend-easing
|
||||
Reset with Mesh Opnieuw instellen met maas
|
||||
Reset with Warp Opnieuw instellen met vervorming
|
||||
Set Mesh Point Color Kleur van maaspunt instellen
|
||||
@@ -6137,6 +6138,11 @@ axis Y Axis Y-as
|
||||
User Brush Library 9 Penseelbibliotheek van gebruiker 9
|
||||
User Brush Library 10 Penseelbibliotheek van gebruiker 10
|
||||
Art Ribbons Illustratielinten
|
||||
Ink and Wash Inkt en wassing
|
||||
Tapered Ink Taps toelopende inkt
|
||||
Brush Pen Penseelstift
|
||||
Soft Wash Zachte wassing
|
||||
Pooling Wash Wassing met rand
|
||||
Zigzag Zigzag
|
||||
Burst Ray Inslagstraal
|
||||
Dash Trail Streepspoor
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3489,6 +3489,7 @@ theme Appearance Wygląd
|
||||
Move Mesh Points Przesuń punkty siatki
|
||||
Move Spine Point Przesuń punkt grzbietu
|
||||
Reshape Spine Segment Zmień kształt segmentu grzbietu
|
||||
Blend Easing Wygładzanie przejścia
|
||||
Reset with Mesh Resetuj z siatką
|
||||
Reset with Warp Resetuj z wypaczeniem
|
||||
Set Mesh Point Color Ustaw kolor punktu siatki
|
||||
@@ -6191,6 +6192,11 @@ axis Y Axis Oś Y
|
||||
User Brush Library 9 Biblioteka pędzli użytkownika 9
|
||||
User Brush Library 10 Biblioteka pędzli użytkownika 10
|
||||
Art Ribbons Wstęgi artystyczne
|
||||
Ink and Wash Tusz i laworowanie
|
||||
Tapered Ink Zwężający się tusz
|
||||
Brush Pen Pisak pędzelkowy
|
||||
Soft Wash Miękkie laworowanie
|
||||
Pooling Wash Laworowanie z obwódką
|
||||
Zigzag Zygzak
|
||||
Burst Ray Promień uderzenia
|
||||
Dash Trail Ślad kreskowany
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1758,6 +1758,7 @@ antiAlias Sharp Nítido
|
||||
Move Slices Mover Fatias
|
||||
Move Spine Point Mover Ponto de Coluna
|
||||
Reshape Spine Segment Remodelar Segmento da Coluna
|
||||
Blend Easing Suavização da Mesclagem
|
||||
Move Brush Mover Pincel
|
||||
Move Swatch Mover Amostra
|
||||
Move Up Mover para Cima
|
||||
@@ -6252,6 +6253,11 @@ axis Y Axis Eixo Y
|
||||
User Brush Library 9 Biblioteca de Pincéis do Usuário 9
|
||||
User Brush Library 10 Biblioteca de Pincéis do Usuário 10
|
||||
Art Ribbons Fitas de Arte
|
||||
Ink and Wash Nanquim e Aguada
|
||||
Tapered Ink Nanquim Afilado
|
||||
Brush Pen Caneta Pincel
|
||||
Soft Wash Aguada Suave
|
||||
Pooling Wash Aguada com Borda
|
||||
Zigzag Zigue-zague
|
||||
Burst Ray Raio de impacto
|
||||
Dash Trail Rastro tracejado
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2101,6 +2101,7 @@ file-filter Templates Шаблоны
|
||||
Move Slices Переместить фрагменты
|
||||
Move Spine Point Переместить точку остова
|
||||
Reshape Spine Segment Изменить форму сегмента остова
|
||||
Blend Easing Сглаживание перехода
|
||||
Move Brush Переместить кисть
|
||||
Move Swatch Переместить образец
|
||||
Move Up Переместить вверх
|
||||
@@ -5948,6 +5949,11 @@ axis Y Axis Ось Y
|
||||
User Brush Library 9 Пользовательская библиотека кистей 9
|
||||
User Brush Library 10 Пользовательская библиотека кистей 10
|
||||
Art Ribbons Художественные ленты
|
||||
Ink and Wash Тушь и размывка
|
||||
Tapered Ink Сужающаяся тушь
|
||||
Brush Pen Ручка-кисть
|
||||
Soft Wash Мягкая размывка
|
||||
Pooling Wash Размывка с ободком
|
||||
Zigzag Зигзаг
|
||||
Burst Ray Ударный луч
|
||||
Dash Trail Пунктирный след
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2015,6 +2015,7 @@ font-technology Variable Variabla
|
||||
Move Slices Flytta skivor
|
||||
Move Spine Point Flytta ryggradspunkt
|
||||
Reshape Spine Segment Omforma ryggradssegment
|
||||
Blend Easing Blandningens acceleration
|
||||
Move Brush Flytta pensel
|
||||
Move Swatch Flytta färgruta
|
||||
Move Up Flytta upp
|
||||
@@ -6354,6 +6355,11 @@ axis Y Axis Y-axel
|
||||
User Brush Library 9 Användarbibliotek för penslar 9
|
||||
User Brush Library 10 Användarbibliotek för penslar 10
|
||||
Art Ribbons Konstband
|
||||
Ink and Wash Tusch och lavering
|
||||
Tapered Ink Avsmalnande tusch
|
||||
Brush Pen Penselpenna
|
||||
Soft Wash Mjuk lavering
|
||||
Pooling Wash Lavering med kant
|
||||
Zigzag Sicksack
|
||||
Burst Ray Stötstråle
|
||||
Dash Trail Streckat spår
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1904,6 +1904,7 @@ font-technology Variable Değişken
|
||||
Move Slices Dilimleri Taşı
|
||||
Move Spine Point Omurga Noktasını Taşı
|
||||
Reshape Spine Segment Omurga Segmentini Yeniden Şekillendirin
|
||||
Blend Easing Karışım Yumuşatma
|
||||
Move Swatch Renk Örneğini Taşı
|
||||
Move Up Yukarı Taşı
|
||||
Move… Taşı…
|
||||
@@ -6293,6 +6294,11 @@ axis Y Axis Y ekseni
|
||||
User Brush Library 9 Kullanıcı Fırça Kitaplığı 9
|
||||
User Brush Library 10 Kullanıcı Fırça Kitaplığı 10
|
||||
Art Ribbons Sanat Şeritleri
|
||||
Ink and Wash Mürekkep ve Lavi
|
||||
Tapered Ink Sivrilen Mürekkep
|
||||
Brush Pen Fırça Kalem
|
||||
Soft Wash Yumuşak Lavi
|
||||
Pooling Wash Kenarlı Lavi
|
||||
Zigzag Zigzag
|
||||
Burst Ray Darbe ışını
|
||||
Dash Trail Kesik iz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2106,6 +2106,7 @@ file-filter Templates Шаблони
|
||||
Move Slices Перемістити фрагменти
|
||||
Move Spine Point Перемістити точку осі
|
||||
Reshape Spine Segment Змінити форму сегмента осі
|
||||
Blend Easing Згладжування переходу
|
||||
Move Brush Перемістити пензель
|
||||
Move Swatch Перемістити зразок
|
||||
Move Up Перемістити вгору
|
||||
@@ -5942,6 +5943,11 @@ axis Y Axis Вісь Y
|
||||
User Brush Library 9 Користувацька бібліотека пензлів 9
|
||||
User Brush Library 10 Користувацька бібліотека пензлів 10
|
||||
Art Ribbons Художні стрічки
|
||||
Ink and Wash Туш і розмивка
|
||||
Tapered Ink Звужена туш
|
||||
Brush Pen Пензлева ручка
|
||||
Soft Wash М'яка розмивка
|
||||
Pooling Wash Розмивка з облямівкою
|
||||
Zigzag Зигзаг
|
||||
Burst Ray Ударний промінь
|
||||
Dash Trail Пунктирний слід
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -3227,6 +3227,7 @@ mark-style Japanese Kiểu Nhật
|
||||
Reshape Định hình lại
|
||||
Reshape Segment Định hình lại đoạn
|
||||
Reshape Spine Segment Định hình lại đoạn trục
|
||||
Blend Easing Gia tốc hòa trộn
|
||||
Reshape Tool Công cụ Định hình lại
|
||||
Reshape Type Area Định hình lại vùng chữ
|
||||
Resize Thay đổi kích thước
|
||||
@@ -5943,6 +5944,11 @@ axis Y Axis Trục Y
|
||||
User Brush Library 9 Thư viện cọ người dùng 9
|
||||
User Brush Library 10 Thư viện cọ người dùng 10
|
||||
Art Ribbons Dải băng nghệ thuật
|
||||
Ink and Wash Mực và màu loang
|
||||
Tapered Ink Mực vuốt nhọn
|
||||
Brush Pen Bút lông
|
||||
Soft Wash Màu loang mềm
|
||||
Pooling Wash Màu loang đọng viền
|
||||
Zigzag Zigzag
|
||||
Burst Ray Tia va chạm
|
||||
Dash Trail Vệt đứt đoạn
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -1968,6 +1968,7 @@ file-filter Templates 模板
|
||||
Move Slices 移动切片
|
||||
Move Spine Point 移动脊线点
|
||||
Reshape Spine Segment 整形脊线线段
|
||||
Blend Easing 渐变缓动
|
||||
@msg select a blend or give `id` 请选择一个混合对象,或给出 `id`
|
||||
@msg not a blend 不是混合对象
|
||||
@msg the blend has no spine 混合对象没有脊线
|
||||
@@ -6175,6 +6176,11 @@ axis Y Axis Y 轴
|
||||
User Brush Library 9 用户画笔库 9
|
||||
User Brush Library 10 用户画笔库 10
|
||||
Art Ribbons 艺术丝带
|
||||
Ink and Wash 水墨淡彩
|
||||
Tapered Ink 渐细墨线
|
||||
Brush Pen 软头笔
|
||||
Soft Wash 柔和淡彩
|
||||
Pooling Wash 积色淡彩
|
||||
Zigzag 锯齿
|
||||
Burst Ray 冲击射线
|
||||
Dash Trail 虚线轨迹
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3060,6 +3060,7 @@ file-filter Templates 範本
|
||||
Move Slices 移動切片
|
||||
Move Spine Point 移動脊線點
|
||||
Reshape Spine Segment 改變脊線線段外框
|
||||
Blend Easing 漸變緩動
|
||||
@msg select a blend or give `id` 請選取漸變物件,或提供 `id`
|
||||
@msg not a blend 不是漸變物件
|
||||
@msg the blend has no spine 漸變物件沒有脊線
|
||||
@@ -5980,6 +5981,11 @@ axis Y Axis Y 軸
|
||||
User Brush Library 9 使用者筆刷資料庫 9
|
||||
User Brush Library 10 使用者筆刷資料庫 10
|
||||
Art Ribbons 藝術緞帶
|
||||
Ink and Wash 水墨淡彩
|
||||
Tapered Ink 漸細墨線
|
||||
Brush Pen 軟頭筆
|
||||
Soft Wash 柔和淡彩
|
||||
Pooling Wash 積色淡彩
|
||||
Zigzag 鋸齒
|
||||
Burst Ray 衝擊射線
|
||||
Dash Trail 虛線軌跡
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -528,7 +528,8 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
|
||||
|
||||
/// "Recent Colors" header + rows of chips (the Session's recent colours, which every paint
|
||||
/// command feeds), wrapped onto as many rows as the panel's width needs; returns the one clicked,
|
||||
/// for the caller to apply.
|
||||
/// for the caller to apply. A chip drags its color as a solid paint, which the Swatches panel
|
||||
/// makes a swatch of.
|
||||
pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
crate::widgets::subheader(ui, tl!("Recent Colors"));
|
||||
@@ -543,7 +544,8 @@ pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Col
|
||||
for (i, c) in colors.iter().enumerate() {
|
||||
let (row, col) = (i / per_row, i % per_row);
|
||||
let cell = Rect::from_min_size(r.min + vec2(3.0 + col as f32 * 19.0, 3.0 + row as f32 * 19.0), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click());
|
||||
let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click_and_drag());
|
||||
crate::widgets::drag_source(ui, &resp, || crate::widgets::PanelDrag::paint(Paint::solid(*c)));
|
||||
crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered());
|
||||
if resp.on_hover_text(c.to_hex()).clicked() {
|
||||
chosen = Some(*c);
|
||||
|
||||
@@ -59,9 +59,10 @@ pub(super) enum Drop {
|
||||
/// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it
|
||||
/// gives that swatch its paint instead of moving.
|
||||
Move { names: Vec<String>, target: Option<String>, after: bool, with: Option<String> },
|
||||
/// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row
|
||||
/// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch
|
||||
/// `target` when that is of its kind and not a tint swatch.
|
||||
/// A paint from elsewhere (a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's
|
||||
/// thumbnail) dropped on row `target` (or between rows) becomes a swatch; with Alt held
|
||||
/// (`replace`) it replaces swatch `target` when that is of its kind and neither Registration
|
||||
/// nor a tint swatch.
|
||||
New { paint: Paint, target: Option<String>, replace: bool },
|
||||
/// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays).
|
||||
Pattern { ids: Vec<u64> },
|
||||
@@ -259,7 +260,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
|
||||
None => return,
|
||||
},
|
||||
// Alt: an edited gradient (or colour) replaces the swatch it is dropped on.
|
||||
Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => {
|
||||
Drop::New { paint, target: Some(t), replace: true }
|
||||
if d.swatch(&t).is_some_and(|w| !w.is_reserved() && w.tint_of().is_none() && same_kind(&w.paint, &paint)) =>
|
||||
{
|
||||
("swatch.edit", json!({"name": t, "paint": crate::panels::swatch_paint_params(&paint)}))
|
||||
}
|
||||
Drop::New { paint, target, .. } => {
|
||||
@@ -279,9 +282,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
|
||||
}
|
||||
|
||||
/// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches,
|
||||
/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance
|
||||
/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the
|
||||
/// thumbnail of a dragged graphic style.
|
||||
/// a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's thumbnail), the fill of the
|
||||
/// object whose appearance the Appearance panel's thumbnail carries (or of the first art dragged
|
||||
/// off the canvas), or the thumbnail of a dragged graphic style.
|
||||
pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(d) = egui::DragAndDrop::payload::<PanelDrag>(ctx) else { return };
|
||||
let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint());
|
||||
|
||||
@@ -437,11 +437,24 @@ fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, tim
|
||||
|
||||
/// [`drag`] with `modifiers` held.
|
||||
fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) {
|
||||
drag_drawn(app, ctx, from, to, modifiers, time, show);
|
||||
}
|
||||
|
||||
/// [`drag_with`] over the panels `draw` draws.
|
||||
fn drag_drawn(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &egui::Context,
|
||||
from: Pos2,
|
||||
to: Pos2,
|
||||
modifiers: Modifiers,
|
||||
time: f64,
|
||||
draw: fn(&mut VectorcraftApp, &mut Ui),
|
||||
) {
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers };
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, draw);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, draw);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, draw);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, draw);
|
||||
}
|
||||
|
||||
fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec<String> {
|
||||
@@ -553,6 +566,124 @@ fn a_proxy_dropped_on_the_panel_becomes_a_swatch() {
|
||||
assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none());
|
||||
}
|
||||
|
||||
/// The center of the Recent Colors chip `i` drawn by `draw` in a frame at `time`: the chip of the
|
||||
/// panel's row, or (`color`) of the Color panel's row, drawn in a child whose scope id `draw` keeps
|
||||
/// in the panel state `"test-color-scope"`.
|
||||
fn recent_chip(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui), color: bool) -> Pos2 {
|
||||
let mut chip = None;
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)));
|
||||
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect, ..Default::default() }, |ui| {
|
||||
draw(app, ui);
|
||||
let scope = if color { pstate::<Option<egui::Id>>(ctx, "test-color-scope").expect("the Color panel is drawn") } else { ui.scope_id() };
|
||||
chip = ctx.read_response(scope.with(("recent", i))).map(|r| r.rect.center());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
chip.unwrap_or_else(|| panic!("recent chip {i} is drawn"))
|
||||
}
|
||||
|
||||
/// A rectangle filled with a CMYK color and then an RGB one, so Recent Colors holds the RGB color
|
||||
/// first and the CMYK one second; → its id and the CMYK color.
|
||||
fn art_with_two_recent_colors(app: &mut VectorcraftApp) -> (vectorcraft_doc::NodeId, Color) {
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": {"c": 10, "m": 60, "y": 20, "k": 5}, "keepModel": true})).unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": "#2050a0"})).unwrap();
|
||||
let cmyk = app.session.recent_colors[1];
|
||||
assert!(matches!(cmyk, Color::Cmyk { .. }), "the second recent color is CMYK: {cmyk:?}");
|
||||
(vectorcraft_doc::NodeId(id), cmyk)
|
||||
}
|
||||
|
||||
/// The fill of art `id`.
|
||||
fn fill_of(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> Option<Paint> {
|
||||
app.session.doc().unwrap().doc.node(id).map(|n| n.appearance.fill_paint())
|
||||
}
|
||||
|
||||
/// A Recent Colors chip dragged onto the swatch list becomes a swatch of its color, in its color
|
||||
/// model, as one undo step; the drag paints no art, and a color that is already a swatch is added
|
||||
/// again under another name.
|
||||
#[test]
|
||||
fn a_recent_color_dragged_onto_the_list_becomes_a_swatch() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
let (id, cmyk) = art_with_two_recent_colors(&mut app);
|
||||
frame(&mut app, &ctx, vec![], 0.0, show);
|
||||
let art = fill_of(&app, id);
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
let (count, brights) = (app.session.doc().unwrap().doc.swatches_iter().count(), names_of(&app, Some("Brights")).len());
|
||||
// Onto a grouped swatch: into that group, at its end.
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false);
|
||||
drag(&mut app, &ctx, chip, tile_center(&ctx, "Bright Red"), 1.0);
|
||||
let d = app.session.doc().unwrap();
|
||||
let group = &d.doc.swatch_groups.iter().find(|g| g.name == "Brights").unwrap().swatches;
|
||||
assert_eq!(group.len(), brights + 1, "{}", app.ui.status);
|
||||
let first = group.last().cloned().expect("a swatch");
|
||||
assert_eq!(first.paint, Paint::solid(cmyk), "the chip's color, still CMYK");
|
||||
assert_eq!(d.history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(fill_of(&app, id), art, "the drag paints no art");
|
||||
assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none());
|
||||
// The same color again, onto an ungrouped swatch: a second swatch under another name.
|
||||
frame(&mut app, &ctx, vec![], 2.0, show);
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 2.5, show, false);
|
||||
drag(&mut app, &ctx, chip, tile_center(&ctx, "Red"), 3.0);
|
||||
let newest = app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch");
|
||||
assert_eq!(newest.paint, Paint::solid(cmyk));
|
||||
assert_ne!(newest.name, first.name, "another name");
|
||||
assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 2);
|
||||
// Undo takes the last one off.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1);
|
||||
}
|
||||
|
||||
/// A color Alt-dropped on Registration, which can't be edited, becomes a new swatch and leaves
|
||||
/// Registration as it is.
|
||||
#[test]
|
||||
fn a_color_alt_dropped_on_registration_becomes_a_new_swatch() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
let (_, cmyk) = art_with_two_recent_colors(&mut app);
|
||||
frame(&mut app, &ctx, vec![], 0.0, show);
|
||||
let registration = app.session.doc().unwrap().doc.swatch(REGISTRATION).map(|w| w.paint.clone());
|
||||
let count = app.session.doc().unwrap().doc.swatches_iter().count();
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false);
|
||||
drag_with(&mut app, &ctx, chip, tile_center(&ctx, REGISTRATION), Modifiers::ALT, 1.0);
|
||||
let d = &app.session.doc().unwrap().doc;
|
||||
assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status);
|
||||
assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk)));
|
||||
assert_eq!(d.swatch(REGISTRATION).map(|w| w.paint.clone()), registration, "Registration is unchanged");
|
||||
}
|
||||
|
||||
/// A Recent Colors chip of the Color panel dragged onto the Swatches panel becomes a swatch; a click
|
||||
/// on it still paints the selected art.
|
||||
#[test]
|
||||
fn a_recent_color_dragged_from_the_color_panel_becomes_a_swatch() {
|
||||
fn both(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
show(app, ui);
|
||||
ui.push_id("color", |ui| {
|
||||
crate::panels::color::show(app, ui);
|
||||
set_pstate(ui.ctx(), "test-color-scope", Some(ui.scope_id()));
|
||||
});
|
||||
}
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
app.ui.color_show_recent_colors = true;
|
||||
let (id, cmyk) = art_with_two_recent_colors(&mut app);
|
||||
frame(&mut app, &ctx, vec![], 0.0, both);
|
||||
let count = app.session.doc().unwrap().doc.swatches_iter().count();
|
||||
let art = fill_of(&app, id);
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 0.5, both, true);
|
||||
drag_drawn(&mut app, &ctx, chip, tile_center(&ctx, "Red"), Modifiers::NONE, 1.0, both);
|
||||
let d = &app.session.doc().unwrap().doc;
|
||||
assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status);
|
||||
assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk)));
|
||||
assert_eq!(fill_of(&app, id), art, "the drag paints no art");
|
||||
// A click on the chip paints the selected art.
|
||||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||||
frame(&mut app, &ctx, vec![], 2.0, both);
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 2.5, both, true);
|
||||
let button = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(chip), button(true), button(false)], 3.0, both);
|
||||
assert_eq!(fill_of(&app, id).and_then(|f| f.color()), Some(cmyk), "a click paints the art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -120,6 +120,26 @@ fn clicking_a_library_brush_adds_it_to_the_documents_brushes_in_one_step() {
|
||||
assert!(last_open(&app.ui).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
let r = app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap();
|
||||
assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("ink-and-wash"), Some(4)));
|
||||
let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1;
|
||||
let rows = BrushLibraries::rows(&lib, "");
|
||||
assert_eq!(rows.iter().map(|r| r.name).collect::<Vec<_>>(), ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]);
|
||||
let path = app.run("shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
let (undo, brushes) = (undo_len(&app), count(&app));
|
||||
click(&mut app, &ctx, "Pooling Wash", Modifiers::NONE, 0.0);
|
||||
assert_eq!((count(&app), undo_len(&app)), (brushes + 1, undo + 1), "one brush, one undo step");
|
||||
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(path)).unwrap().clone();
|
||||
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.clone()).as_deref(), Some("Pooling Wash"));
|
||||
click(&mut app, &ctx, "Brush Pen", Modifiers::NONE, 2.0);
|
||||
assert!(has(&app, "Brush Pen"));
|
||||
assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Brush Pen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_menu_and_the_brushes_panel_open_brush_libraries() {
|
||||
let mut app = app();
|
||||
@@ -131,6 +151,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() {
|
||||
opened,
|
||||
[
|
||||
json!("art-ribbons"),
|
||||
json!("ink-and-wash"),
|
||||
json!("calligraphic-nibs"),
|
||||
json!("bristle-brushes"),
|
||||
json!("pattern-borders"),
|
||||
|
||||
@@ -787,8 +787,20 @@ fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo)
|
||||
// The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)…
|
||||
let arrows = [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)];
|
||||
for (k, tk) in arrows {
|
||||
// The press's Shift and Alt (the consume below takes those presses too) go to a tool that
|
||||
// claims the arrow (Shift steps a blend widget by ten); other tools get the plain arrow.
|
||||
let pressed = ctx.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } if *key == k && !(modifiers.command || modifiers.ctrl) => Some(*modifiers),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
|
||||
let _ = app.session.tool_key(tk, Mods::default(), view);
|
||||
let mods = match pressed {
|
||||
Some(m) if app.session.tool_claims_key(tk, view) => crate::canvas::mods(m, false),
|
||||
_ => Mods::default(),
|
||||
};
|
||||
let _ = app.session.tool_key(tk, mods, view);
|
||||
}
|
||||
}
|
||||
// Digits (5 while dragging with the Perspective Selection tool), once per press.
|
||||
|
||||
@@ -3032,6 +3032,38 @@ fn a_plain_up_nudges_unless_a_corner_widget_is_pressed() {
|
||||
assert_eq!(kind2, CornerKind::InvertedRound, "Up stepped the kind");
|
||||
}
|
||||
|
||||
/// While a blend widget is pressed, an arrow steps its value by one and Shift with an arrow by ten.
|
||||
#[test]
|
||||
fn shift_arrows_step_a_pressed_blend_widget_by_ten() {
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_tools::blendwidget::BlendWidget;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let rect = |app: &mut VectorcraftApp, x: f64| {
|
||||
app.session.execute("shape.rectangle", &json!({"x": x, "y": 190, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (a, b) = (rect(&mut app, 90.0), rect(&mut app, 290.0));
|
||||
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
let id = NodeId(app.session.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
app.select_tool("selection");
|
||||
app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let shift = |app: &VectorcraftApp| {
|
||||
let NodeKind::Blend { spec, .. } = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("not a blend") };
|
||||
spec.color_shift
|
||||
};
|
||||
let view = app.view_info();
|
||||
let st = app.session.active().unwrap();
|
||||
let at = BlendWidget::of(&st.doc, &st.selection, view.zoom, vectorcraft_geom::Vec2::new(0.0, -1.0))[0].at;
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), view).unwrap();
|
||||
frame(&mut app, press(Key::ArrowUp, Modifiers::SHIFT));
|
||||
assert_eq!(shift(&app), 10.0, "Shift+Up steps by ten");
|
||||
frame(&mut app, press(Key::ArrowUp, Modifiers::NONE));
|
||||
assert_eq!(shift(&app), 11.0, "Up steps by one");
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Up, at.x, at.y), view).unwrap();
|
||||
assert_eq!(shift(&app), 11.0);
|
||||
}
|
||||
|
||||
/// #5218: the status bar navigator's four buttons are commands of their own, listed in Edit ›
|
||||
/// Keyboard Shortcuts without a default key, so a user can give them one (Page Up and Page Down
|
||||
/// here) and step through the artboards from the keyboard.
|
||||
|
||||
@@ -128,6 +128,45 @@ fn control_saves_leave_out_the_documents_file_outside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// In one session with a library loaded from outside the read root: the library keeps its file-name
|
||||
/// id, a reply that names its path shows the file name, a save without a path leaves out the
|
||||
/// document's file, and a save or export to a path the client gave in the write root keeps that path
|
||||
/// in `path` and `files`, also in a `command.batch` step's result. A refused path outside the write root is still shown by its file name.
|
||||
#[test]
|
||||
fn control_saves_keep_the_given_path_beside_a_hidden_library() {
|
||||
let (mut app, tx, base) = confined("save-and-library");
|
||||
let library = s(base.join("outside/b.svg"));
|
||||
app.ui.color_guide_limit = format!("loaded/{library}");
|
||||
app.run("file.new", json!({"width": 100, "height": 50})).unwrap();
|
||||
let own = s(base.join("out/doc.vectorcraft"));
|
||||
app.run("file.saveAs", json!({"path": own})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let r = request(&mut app, &tx, "app.save", json!({}));
|
||||
assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{r}");
|
||||
let r = request(&mut app, &tx, "app.save", json!({"path": own}));
|
||||
assert_eq!(r["result"]["path"], json!(own), "{r}");
|
||||
let split = s(base.join("out/split.vectorcraft"));
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.save", "params": {"path": split, "separateArtboards": true}}));
|
||||
assert_eq!(r["result"]["path"], json!(split), "{r}");
|
||||
assert_eq!(r["result"]["files"][0], json!(split), "{r}");
|
||||
let batched = s(base.join("out/batched.vectorcraft"));
|
||||
let steps = json!({"commands": [{"command": "document.save", "params": {"path": batched}}]});
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "command.batch", "params": steps}));
|
||||
assert_eq!(r["result"]["results"][0]["path"], json!(batched), "{r}");
|
||||
let svg = s(base.join("out/a.svg"));
|
||||
let r = request(&mut app, &tx, "app.export", json!({"path": svg, "wait": true}));
|
||||
assert_eq!(r["result"]["path"], json!(svg), "{r}");
|
||||
app.ui.status = format!("from {library}");
|
||||
let r = request(&mut app, &tx, "test.status", json!({}));
|
||||
assert_eq!(r["result"], "from b.svg", "{r}");
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}");
|
||||
let refused = s(base.join("outside/c.vectorcraft"));
|
||||
let r = request(&mut app, &tx, "app.save", json!({"path": refused, "wait": true}));
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the write root") && !e.contains(&refused)), "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true`
|
||||
/// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it
|
||||
/// before they reply. Without `wait` the refusal reaches only the status bar.
|
||||
@@ -446,6 +485,366 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before
|
||||
/// it is a relative path, outside the root, and is left out.
|
||||
#[test]
|
||||
fn ui_inspect_checks_a_folder_preference_as_written() {
|
||||
let (mut app, tx, base) = confined("inspect-folder-space");
|
||||
let inside = s(base.join("in"));
|
||||
app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")});
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap();
|
||||
assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}");
|
||||
assert!(!prefs.contains_key("templatesFolder"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` checks a folder preference as the app uses it, trimmed, as well: with the working
|
||||
/// folder as the read root, a path outside it with a space before it reads as a name in the working
|
||||
/// folder, and is still left out, because the app trims it and uses the path outside.
|
||||
#[test]
|
||||
fn ui_inspect_checks_a_folder_preference_as_trimmed() {
|
||||
let (_, _, base) = confined("inspect-folder-trimmed");
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let roots = AutomationRoots::new(&[&cwd], &[&base.join("out")]).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx).with_automation_roots(roots);
|
||||
let outside = s(base.join("outside"));
|
||||
// Without its drive, the path starts with a separator: trimmed, it is absolute on any platform.
|
||||
let rooted = match outside.as_bytes() {
|
||||
[drive, b':', ..] if drive.is_ascii_alphabetic() => &outside[2..],
|
||||
_ => outside.as_str(),
|
||||
};
|
||||
let spaced = format!(" {rooted}");
|
||||
app.ui.engine_prefs = json!({"fontsFolder": spaced, "pluginsFolder": ""});
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap();
|
||||
assert_eq!(prefs.get("pluginsFolder").and_then(Value::as_str), Some(""), "{r}");
|
||||
assert!(!prefs.contains_key("fontsFolder"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the
|
||||
/// command palette query and a script's report show the id and file name shown, and the path appears
|
||||
/// nowhere in the reply.
|
||||
#[test]
|
||||
fn ui_inspect_hides_a_hidden_library_path_in_every_string() {
|
||||
let (mut app, tx, base) = confined("inspect-status-id");
|
||||
let path = s(base.join("outside/b.svg"));
|
||||
let theirs = format!("loaded/{path}");
|
||||
app.ui.color_guide_limit = theirs.clone();
|
||||
app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak");
|
||||
app.ui.palette_query = format!("\"{theirs}\"");
|
||||
let source = format!("vc.log({})", json!(format!("read {path}.")));
|
||||
app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap();
|
||||
for _ in 0..10_000 {
|
||||
if app.session.script_pump().is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}");
|
||||
assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}");
|
||||
assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}");
|
||||
assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}");
|
||||
let outside = json!(s(base.join("outside"))).to_string();
|
||||
assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that
|
||||
/// library's id wherever the reply holds it, also between spaces and brackets and where a character
|
||||
/// that continues a path follows it.
|
||||
#[test]
|
||||
fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() {
|
||||
let (mut app, tx, base) = confined("named-hidden-reply");
|
||||
let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
|
||||
app.ui.color_guide_limit = theirs.clone();
|
||||
app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old");
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"}));
|
||||
assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}");
|
||||
let outside = json!(s(base.join("outside"))).to_string();
|
||||
assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id,
|
||||
/// and its path appears nowhere in the reply.
|
||||
#[test]
|
||||
fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() {
|
||||
let (mut app, tx, base) = confined("named-hidden-error");
|
||||
app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg")));
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}");
|
||||
let outside = json!(s(base.join("outside"))).to_string();
|
||||
assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path
|
||||
/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside
|
||||
/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays.
|
||||
#[test]
|
||||
fn ui_inspect_leaves_out_recorded_paths_under_any_key() {
|
||||
let (mut app, tx, base) = confined("inspect-action-keys");
|
||||
let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")));
|
||||
let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) };
|
||||
let (url, url_in) = (to_url(&b), to_url(&a));
|
||||
let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({
|
||||
"name": "set",
|
||||
"actions": [{"name": "files", "steps": [
|
||||
["document.compare", {"diffPath": b, "target": a}],
|
||||
["file.open", {"file": b}],
|
||||
["type.fonts", {"fontFiles": [a, b, url_in]}],
|
||||
["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}],
|
||||
["x.z", {"list": [b, "keep"]}],
|
||||
["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}],
|
||||
["x.v", {"file": url_in}],
|
||||
]}],
|
||||
}))
|
||||
.unwrap();
|
||||
app.ui.action_sets = vec![set];
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"];
|
||||
let want = json!([
|
||||
["document.compare", {"target": a}],
|
||||
["file.open", {}],
|
||||
["type.fonts", {"fontFiles": [a, url_in]}],
|
||||
["x.y", {"other": "keep"}],
|
||||
["x.z", {"list": ["keep"]}],
|
||||
["x.w", {"other": "keep"}],
|
||||
["x.v", {"file": url_in}],
|
||||
]);
|
||||
assert_eq!(steps, &want, "{r}");
|
||||
let outside = json!(s(base.join("outside"))).to_string();
|
||||
assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action
|
||||
/// (its `library` and `panel` params) and in the action's name, also when nothing else names it.
|
||||
#[test]
|
||||
fn ui_inspect_hides_a_hidden_library_in_recorded_steps() {
|
||||
let (mut app, tx, base) = confined("inspect-action-steps");
|
||||
let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
|
||||
let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({
|
||||
"name": "set",
|
||||
"actions": [{"name": theirs, "steps": [
|
||||
["swatch.library.add", {"library": theirs}],
|
||||
["window.panel", {"panel": format!("library:swatches:{theirs}")}],
|
||||
]}],
|
||||
}))
|
||||
.unwrap();
|
||||
app.ui.action_sets = vec![set];
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let action = &r["result"]["ui"]["action_sets"][0]["actions"][0];
|
||||
assert_eq!(action["name"], "loaded/b.svg", "{r}");
|
||||
assert_eq!(
|
||||
action["steps"],
|
||||
json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]),
|
||||
"{r}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending
|
||||
/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones
|
||||
/// inside it.
|
||||
#[test]
|
||||
fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() {
|
||||
let (mut app, tx, base) = confined("inspect-action-paths");
|
||||
let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")));
|
||||
let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({
|
||||
"name": "set",
|
||||
"actions": [{"name": "files", "steps": [
|
||||
["file.open", {"path": a}],
|
||||
["file.open", {"path": b}],
|
||||
["file.place", {"paths": [a, b]}],
|
||||
["batch.run", {"folder": s(base.join("outside"))}],
|
||||
["action.batch", {"destinationFolder": s(base.join("outside"))}],
|
||||
["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}],
|
||||
]}],
|
||||
}))
|
||||
.unwrap();
|
||||
app.ui.action_sets = vec![set];
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"];
|
||||
let want = json!([
|
||||
["file.open", {"path": a}],
|
||||
["file.open", {}],
|
||||
["file.place", {"paths": [a]}],
|
||||
["batch.run", {}],
|
||||
["action.batch", {}],
|
||||
["command.batch", {"commands": [{"command": "file.open", "params": {}}]}],
|
||||
]);
|
||||
assert_eq!(steps, &want, "{r}");
|
||||
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with
|
||||
/// either separator, also one saved on another platform.
|
||||
#[test]
|
||||
fn ui_inspect_hides_a_library_path_with_either_separator() {
|
||||
let (mut app, tx, base) = confined("inspect-separators");
|
||||
app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string();
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}");
|
||||
app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string();
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A hidden library does not take the id of a library loaded without a path under the same file
|
||||
/// name: it shows as `loaded/<file name> (2)`, and a request that names the other library reaches it.
|
||||
#[test]
|
||||
fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() {
|
||||
let (mut app, tx, base) = confined("inspect-loaded-id");
|
||||
let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap();
|
||||
let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap();
|
||||
assert_eq!(loaded["library"], "loaded/b.svg");
|
||||
let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
|
||||
app.ui.color_guide_limit = theirs;
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Hidden libraries with the same file name that are shown for the first time in the same reply are
|
||||
/// numbered in the order of their paths, as the library commands number them.
|
||||
#[test]
|
||||
fn hidden_libraries_are_numbered_in_the_order_of_their_paths() {
|
||||
let (mut app, tx, base) = confined("hidden-load-order");
|
||||
let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n";
|
||||
for dir in ["z", "a"] {
|
||||
std::fs::create_dir_all(base.join("outside").join(dir)).unwrap();
|
||||
let path = base.join("outside").join(dir).join("b.gpl");
|
||||
std::fs::write(&path, gpl).unwrap();
|
||||
app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap();
|
||||
}
|
||||
app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("z").join("b.gpl")));
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A hidden library keeps the id a reply showed for it when another library with the same file name is
|
||||
/// loaded later.
|
||||
#[test]
|
||||
fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() {
|
||||
let (mut app, tx, base) = confined("hidden-stable-id");
|
||||
let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl")));
|
||||
let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap();
|
||||
app.ui.library_panels = vec![panel];
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}");
|
||||
std::fs::create_dir_all(base.join("outside").join("z")).unwrap();
|
||||
let path = base.join("outside").join("z").join("b.gpl");
|
||||
std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap();
|
||||
app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap();
|
||||
app.ui.color_guide_limit = format!("loaded/{}", s(path));
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}");
|
||||
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// An error from a panic hides a hidden library's path, also when the request stopped referring to
|
||||
/// the library before it panicked.
|
||||
#[test]
|
||||
fn a_panic_reply_hides_a_library_hidden_before_the_request() {
|
||||
let (mut app, tx, base) = confined("panic-hidden");
|
||||
let path = s(base.join("outside/b.svg"));
|
||||
app.ui.color_guide_limit = format!("loaded/{path}");
|
||||
app.ui.status = format!("Loaded {path}");
|
||||
let r = request(&mut app, &tx, "test.panic", json!({"forget": true}));
|
||||
assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// An error shows a path outside the read root that the request sent by its file name, whether or not
|
||||
/// a hidden library was loaded from that path, so that it reads the same either way. A path the
|
||||
/// request sends does not show the app's own text that holds it, in an error or a result, and a result
|
||||
/// shows that path by its file name as an error does.
|
||||
#[test]
|
||||
fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() {
|
||||
let (mut app, tx, base) = confined("client-echo");
|
||||
let path = s(base.join("outside/b.svg"));
|
||||
// Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`.
|
||||
let first = format!("loaded/{}", s(base.join("outside/a/b.svg")));
|
||||
let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap();
|
||||
app.ui.library_panels = vec![panel];
|
||||
app.ui.color_guide_limit = format!("loaded/{path}");
|
||||
app.ui.status = format!("Loaded {path}");
|
||||
let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")];
|
||||
for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) {
|
||||
let r = request(&mut app, &tx, "app.open", json!({"path": sent}));
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}");
|
||||
}
|
||||
let r = request(&mut app, &tx, "test.status", json!({"guess": path}));
|
||||
assert_eq!(r["result"], "Loaded b.svg", "{r}");
|
||||
let r = request(&mut app, &tx, "test.panic", json!({"guess": path}));
|
||||
assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A screenshot's reply, sent after its request was handled, hides a hidden library's path, and in an
|
||||
/// error the path outside the read root that the request sent.
|
||||
#[test]
|
||||
fn a_screenshot_reply_hides_paths_like_any_reply() {
|
||||
let (mut app, _tx, base) = confined("deferred-reply");
|
||||
let path = s(base.join("outside/b.svg"));
|
||||
app.ui.color_guide_limit = format!("loaded/{path}");
|
||||
let sent = s(base.join("outside/shot.png"));
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
|
||||
let mut done = json!({"ok": true, "result": {"note": format!("from {path}")}});
|
||||
let mut failed = json!({"ok": false, "error": format!("could not write {sent}")});
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
crate::control::hide_in_deferred_reply(&app, &mut done, &json!({"path": sent}));
|
||||
crate::control::hide_in_deferred_reply(&app, &mut failed, &json!({"path": sent}));
|
||||
});
|
||||
assert_eq!(done["result"]["note"], "from b.svg", "{done}");
|
||||
assert_eq!(failed["error"], "could not write shot.png", "{failed}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped
|
||||
/// shows the file name alone.
|
||||
#[test]
|
||||
fn a_reply_hides_a_path_with_escaped_separators() {
|
||||
let (mut app, tx, base) = confined("escaped-separators");
|
||||
let path = s(base.join("outside/b.svg"));
|
||||
app.ui.color_guide_limit = format!("loaded/{path}");
|
||||
app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C"));
|
||||
let r = request(&mut app, &tx, "test.status", json!({}));
|
||||
assert_eq!(r["result"], "from b.svg", "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and
|
||||
/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and
|
||||
/// other text with a separator stay.
|
||||
#[test]
|
||||
fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() {
|
||||
let (mut app, tx, base) = confined("inspect-relative");
|
||||
let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({
|
||||
"name": "set",
|
||||
"actions": [{"name": "files", "steps": [
|
||||
["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}],
|
||||
["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}],
|
||||
]}],
|
||||
}))
|
||||
.unwrap();
|
||||
app.ui.action_sets = vec![set];
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"];
|
||||
let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]);
|
||||
assert_eq!(steps, &want, "{r}");
|
||||
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.
|
||||
@@ -473,6 +872,43 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A library panel command given a hidden library's shown id opens the library and answers with
|
||||
/// that id, not the library's path.
|
||||
#[test]
|
||||
fn a_library_panel_command_answers_with_a_hidden_librarys_shown_id() {
|
||||
let (mut app, tx, base) = confined("panel-shown-id");
|
||||
let path = base.join("outside/x/Kit.gpl");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "GIMP Palette\nName: Kit\n0 0 0 Black\n").unwrap();
|
||||
app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap();
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.swatchLibrary", "params": {"library": "loaded/Kit.gpl"}}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(r.to_string().contains("loaded/Kit.gpl") && !r.to_string().contains("outside"), "{r}");
|
||||
assert_eq!(app.ui.library_panels.last().map(|p| p.id.clone()), Some(format!("loaded/{}", s(path))), "the app's own id");
|
||||
// The panel id it answers with names the panel in the panel commands.
|
||||
let panel = r["result"]["panel"].clone();
|
||||
assert_eq!(panel, "library:swatches:loaded/Kit.gpl", "{r}");
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}}));
|
||||
assert_eq!((&r["ok"], &r["result"]["panel"]), (&json!(true), &panel), "{r}");
|
||||
assert!(!r.to_string().contains("outside"), "{r}");
|
||||
let r = request(
|
||||
&mut app,
|
||||
&tx,
|
||||
"engine.execute",
|
||||
json!({"command": "window.panel.move", "params": {"panel": "swatches", "anchor": panel, "zone": "tab"}}),
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(!r.to_string().contains("outside"), "{r}");
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.panel.close", "params": {"panel": panel}}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(!r.to_string().contains("outside"), "{r}");
|
||||
// Closed, the panel isn't found, and the error names it by the id given.
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(!r.to_string().contains("outside"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A closed panel's former place, as `ui.docking_hidden` keeps it.
|
||||
fn hidden_place() -> craft_ui::docking::Location<String> {
|
||||
serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap()
|
||||
@@ -611,6 +1047,103 @@ fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable
|
||||
/// library's id that begins with it stays, and a script's report line and a command palette query
|
||||
/// that end with it show the id shown.
|
||||
#[test]
|
||||
fn ui_inspect_rewrites_only_whole_hidden_library_ids() {
|
||||
let (mut app, tx, base) = confined("inspect-whole-id");
|
||||
let hidden = format!("loaded/{}", s(base.join("i")));
|
||||
let readable = format!("loaded/{}", s(base.join("in/a.svg")));
|
||||
assert!(readable.starts_with(&hidden));
|
||||
let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() };
|
||||
app.ui.library_panels = vec![panel(&hidden), panel(&readable)];
|
||||
app.ui.palette_query = format!("library:swatches:{hidden}");
|
||||
let text = format!("found {hidden}");
|
||||
let source = format!("vc.log({})", json!(text));
|
||||
app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap();
|
||||
for _ in 0..10_000 {
|
||||
if app.session.script_pump().is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
|
||||
assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}");
|
||||
assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}");
|
||||
assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}");
|
||||
// A request that names the hidden library by the id shown keeps the readable id too.
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"}));
|
||||
let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
|
||||
assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id
|
||||
/// reopens the closed panel of the library, and the reply does not show its path.
|
||||
#[test]
|
||||
fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() {
|
||||
let (mut app, tx, base) = confined("set-hidden-panel");
|
||||
let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
|
||||
let real = format!("library:swatches:{theirs}");
|
||||
app.ui.docking_hidden.insert(real.clone(), hidden_place());
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let shown = "library:swatches:loaded/b.svg";
|
||||
assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}");
|
||||
let r = request(&mut app, &tx, "ui.set", json!({"panel": shown}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}");
|
||||
let layout = crate::panel_docking::layout_of(&app.ui);
|
||||
assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}");
|
||||
let hidden = json!(s(base.join("outside"))).to_string();
|
||||
assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
|
||||
// A docking command takes the shown id as its destination too.
|
||||
let r = request(
|
||||
&mut app,
|
||||
&tx,
|
||||
"engine.execute",
|
||||
json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}),
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
|
||||
// So does `ui.dock`'s move, whose destination is `target`.
|
||||
let r = request(
|
||||
&mut app,
|
||||
&tx,
|
||||
"engine.execute",
|
||||
json!({"command": "ui.dock", "params": {"operation": "move", "panel": "properties", "target": shown, "zone": "tab"}}),
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let layout = crate::panel_docking::layout_of(&app.ui);
|
||||
assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}");
|
||||
assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
|
||||
// An error that quotes the panel shows the id the client gave, and other text is not rewritten.
|
||||
let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
|
||||
let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"}));
|
||||
assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A shortcut for showing the panel of a library outside the read root, saved and in an open Keyboard
|
||||
/// Shortcuts dialog, is keyed by the panel id shown, also when nothing else refers to the library.
|
||||
#[test]
|
||||
fn ui_inspect_hides_a_library_panels_path_in_shortcut_overrides() {
|
||||
let (mut app, tx, base) = confined("shortcut-hidden-panel");
|
||||
let real = format!("panel:library:swatches:loaded/{}", s(base.join("outside/b.svg")));
|
||||
crate::keymap::set_override(&mut app.ui.shortcut_overrides, &real, "Cmd+Alt+Shift+F7");
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("shortcuts", json!({"overrides": { real.clone(): "Cmd+Alt+Shift+F8" }})));
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
let shown = "panel:library:swatches:loaded/b.svg";
|
||||
let ui = &r["result"]["ui"];
|
||||
assert_eq!(ui["shortcut_overrides"][shown], "Cmd+Alt+Shift+F7", "{r}");
|
||||
assert_eq!(ui["dialog"]["fields"]["overrides"][shown], "Cmd+Alt+Shift+F8", "{r}");
|
||||
let hidden = json!(s(base.join("outside"))).to_string();
|
||||
assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A client without automation roots sees every loaded library's full id in `ui.inspect`.
|
||||
#[test]
|
||||
fn ui_inspect_shows_full_library_ids_without_a_read_root() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user