Merge remote-tracking branch 'upstream/main' into fix/2414-svg-classes

# Conflicts:
#	docs/roadmap.md
#	docs/target-app-parity.md
This commit is contained in:
vangel76
2026-10-22 00:32:56 +02:00
94 changed files with 4089 additions and 254 deletions
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+12 -4
View File
@@ -247,10 +247,13 @@ fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
// Saved workspaces and the layout changes kept for each workspace carry the same layouts.
for key in ["custom_workspaces", "workspace_changes"] {
if let Some(Value::Array(workspaces)) = object.get_mut(key) {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
}
}
}
}
@@ -1065,6 +1068,7 @@ mod tests {
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
@@ -1075,6 +1079,8 @@ mod tests {
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
raw["workspace_changes"][0]["docking"] = invalid_layout.clone();
raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
@@ -1091,6 +1097,8 @@ mod tests {
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost");
assert!(loaded.workspace_changes[0].docking.is_none());
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
+4
View File
@@ -148,5 +148,9 @@ sync_github_name = true
[people.addibble]
sync_github_name = true
[people.mockydevs]
real_name = "Don Omondi"
display_name = "Don Omondi"
[people.alpha-rock]
display_name = "Aditya Sisodiya"
+34 -3
View File
@@ -264,6 +264,8 @@ pub struct RowMap {
mean_l: f32,
/// Hue turn from the key colour to the new colour (Hue Shift).
turn: f32,
/// Whether colours take the new colour in its own model, not in the model they had.
own_model: bool,
}
impl RowMap {
@@ -274,7 +276,20 @@ impl RowMap {
Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::<Vec<_>>()),
_ => 0.0,
};
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] }
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0], own_model: false }
}
/// With `own`, current colours take the new colour in the new colour's model (an RGB colour
/// replaced by a CMYK one becomes CMYK) instead of keeping the model they had.
pub fn with_own_model(mut self, own: bool) -> Self {
self.own_model = own;
self
}
/// True when current colour `c` keeps a colour model other than the new colour's (so the new
/// colour is converted into `c`'s model and may look different from what was asked for).
pub fn converts(&self, c: Color) -> bool {
!self.own_model && c.model() != self.to.model()
}
/// The colour of the row the new colour stands for.
@@ -301,7 +316,7 @@ impl RowMap {
/// The new colour of current colour `c`, in `c`'s model.
pub fn map(&self, c: Color) -> Color {
let to = keep_model(c, self.to);
let to = if self.own_model { self.to } else { keep_model(c, self.to) };
match self.method {
Method::Exact => to,
Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)),
@@ -314,7 +329,13 @@ impl RowMap {
Method::HueShift if c == self.key => to,
Method::HueShift => {
let [h, s, v] = c.to_hsb();
if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) }
if s <= 0.0 {
if self.own_model { c.in_model(self.to.model()) } else { c }
} else if self.own_model {
Color::from_hsb(h + self.turn, s, v).in_model(self.to.model())
} else {
keep_model(c, Color::from_hsb(h + self.turn, s, v))
}
}
}
}
@@ -576,6 +597,16 @@ mod tests {
assert!(cluster(&[], &[], 3).is_empty());
}
#[test]
fn hue_shift_with_own_model_puts_every_colour_of_the_row_in_the_new_model() {
let to = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let row = [Color::rgb(0.2, 0.2, 0.9), Color::rgb(0.1, 0.1, 0.5), Color::rgb(0.3, 0.3, 0.3)];
let own = RowMap::new(Method::HueShift, &row, to).with_own_model(true);
assert!(row.iter().all(|c| own.map(*c).model() == to.model()), "key and other colours alike");
let kept = RowMap::new(Method::HueShift, &row, to);
assert!(row.iter().all(|c| kept.map(*c).model() == c.model()));
}
#[test]
fn palette_snaps_to_the_nearest_colour() {
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
+68 -10
View File
@@ -579,11 +579,18 @@ impl Lerp {
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
pub fn at(&self, t: f64) -> Node {
self.at_eased(t, t)
}
/// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text
/// colours) are at `tc`: a blend with its colours eased apart from its spacing.
pub fn at_eased(&self, t: f64, tc: f64) -> Node {
let (a, b) = (&self.a, &self.b);
let base = if t < 0.5 { a } else { b };
let mut n = match &self.shape {
Shape::Group { children, clip } => {
let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip });
let mut g =
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip });
g.isolate = base.isolate;
g.knockout = base.knockout;
g
@@ -593,8 +600,8 @@ impl Lerp {
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
p.p = x.p.lerp(y.p, t);
p.color = lerp_color(&x.color, &y.color, t as f32);
p.opacity = lerp_f32(x.opacity, y.opacity, t as f32);
p.color = lerp_color(&x.color, &y.color, tc as f32);
p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32);
for h in 0..4 {
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
}
@@ -620,7 +627,7 @@ impl Lerp {
Shape::Placed => {
let mut n = base.clone();
match (&mut n.kind, &a.kind, &b.kind) {
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t),
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc),
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
*o = lerp_affine(*x, *y, t)
}
@@ -657,8 +664,8 @@ impl Lerp {
}
};
n.id = NodeId(0);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into();
n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32);
n.blend = base.blend;
n.visible = true;
n.locked = false;
@@ -688,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
/// the same place (by character) of both.
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
o.xf = lerp_affine(x.xf, y.xf, t);
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
(&mut o.kind, &x.kind, &y.kind)
@@ -710,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
}
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
let mut start = 0usize;
let tf = t as f32;
let tf = tc as f32;
let mut resized = false;
for r in &mut o.runs {
let len = r.text.chars().count();
@@ -1071,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
let n = blend_step_count(a, b, spec.spacing, len);
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
for j in 1..=n {
let t = j as f64 / (n + 1) as f64;
let mut s = lerp.at(t);
// Even position of the step, then where the easing puts its shape and its colours.
let u = j as f64 / (n + 1) as f64;
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
let mut s = lerp.at_eased(t, tc);
if let Some(r) = &rail {
r.place(&mut s, i, t);
}
@@ -1134,6 +1143,55 @@ mod tests {
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
}
#[test]
fn easing_keeps_the_ends_and_stays_monotone() {
use crate::live::{BlendEase, BlendEaseType as K};
for kind in [K::In, K::Out, K::InOut, K::OutIn] {
for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] {
let e = BlendEase { kind, ramp };
assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0));
let ys: Vec<f64> = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect();
assert!(ys.windows(2).all(|w| w[0] <= w[1]));
assert_eq!(e.apply(f64::NAN), 0.0);
if ramp == 0.0 {
assert_eq!(e.apply(0.25), 0.25);
}
}
}
let e = |kind| BlendEase { kind, ramp: 1.0 };
assert!(e(K::In).apply(0.25) < 0.25);
assert!(e(K::Out).apply(0.25) > 0.25);
assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75);
assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75);
}
#[test]
fn colour_easing_moves_colours_apart_from_positions() {
let rect = |x: f64, c: Color| {
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default());
n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into();
Arc::new(n)
};
let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)];
let mid = |spec: &BlendSpec| {
let out = blend_expand(&keys, spec);
let n = &out[1];
(n.geometric_bounds().unwrap().x0, n.appearance.fill_paint())
};
// Position easing runs along a spine: Align to Path gives the blend one.
let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
let (x0, p0) = mid(&base);
// Colour easing alone leaves the position and moves the colour.
let colour = BlendSpec { color_shift: 50.0, ..base.clone() };
let (x1, p1) = mid(&colour);
assert!((x0 - x1).abs() < 1e-9 && p0 != p1);
// Position easing alone leaves the colour and moves the position.
let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() };
let (x2, p2) = mid(&pos);
assert!((x0 - x2).abs() > 1e-3 && p0 == p2);
assert_eq!(mid(&pos).0, x2, "deterministic");
}
#[test]
fn affine_lerp_turns_the_short_way() {
let x = Affine::rotate(170f64.to_radians());
+131 -1
View File
@@ -99,7 +99,7 @@ pub use graph::{
};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use live_paint::{GapOptions, GapSize};
pub use metadata::{CopyrightStatus, DocMetadata};
pub use node::Knockout;
@@ -935,6 +935,136 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// Is everything but the layers the same in `other`, as the next version of the document after
/// an edit of its art? The id counter and the id index are left out, and art outside the layers
/// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it
/// doesn't change: quick for every frame of an animation.
pub fn same_outside_layers(&self, other: &Self) -> bool {
let same_art = |a: &[Arc<Node>], b: &[Arc<Node>]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b));
let same_pattern = |a: &PatternDef, b: &PatternDef| {
same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() }
};
let Self {
version,
title,
template,
units,
ruler_origin,
rulers,
artboard_ruler_origins,
color_mode,
artboards,
layers: _,
swatches,
swatch_groups,
graphic_styles,
char_styles,
para_styles,
text_threads,
symbols,
graph_designs,
guides,
views,
saved_selections,
variables,
grid,
raster_effects_ppi,
images,
patterns,
pattern_edit,
mask_edit,
next_id: _,
unknown,
page_isolate,
page_knockout,
spot_use_lab,
setup,
paste_remembers_layers,
metadata,
raster_effects,
last_view,
transparency_grid,
layers_open,
color_profiles,
export_settings,
extra,
slices,
slices_clip_to_artboard,
print_setup,
assets,
svg_scripts,
puppet,
new_art,
dimensions,
index: _,
} = self;
*version == other.version
&& *title == other.title
&& *template == other.template
&& *units == other.units
&& *ruler_origin == other.ruler_origin
&& *rulers == other.rulers
&& *artboard_ruler_origins == other.artboard_ruler_origins
&& *color_mode == other.color_mode
&& *artboards == other.artboards
&& *swatches == other.swatches
&& *swatch_groups == other.swatch_groups
&& *graphic_styles == other.graphic_styles
&& *char_styles == other.char_styles
&& *para_styles == other.para_styles
&& *text_threads == other.text_threads
&& symbols.len() == other.symbols.len()
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
&& graph_designs.len() == other.graph_designs.len()
&& graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
&& *guides == other.guides
&& *views == other.views
&& *saved_selections == other.saved_selections
&& *variables == other.variables
&& *grid == other.grid
&& *raster_effects_ppi == other.raster_effects_ppi
&& *images == other.images
&& patterns.len() == other.patterns.len()
&& patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b))
&& match (pattern_edit, &other.pattern_edit) {
(Some(a), Some(b)) => {
a.pattern == b.pattern
&& a.layer == b.layer
&& match (&a.original, &b.original) {
(Some(a), Some(b)) => same_pattern(a, b),
(a, b) => a.is_none() && b.is_none(),
}
}
(a, b) => a.is_none() && b.is_none(),
}
&& *mask_edit == other.mask_edit
&& *unknown == other.unknown
&& *page_isolate == other.page_isolate
&& *page_knockout == other.page_knockout
&& *spot_use_lab == other.spot_use_lab
&& *setup == other.setup
&& *paste_remembers_layers == other.paste_remembers_layers
&& *metadata == other.metadata
&& *raster_effects == other.raster_effects
&& *last_view == other.last_view
&& *transparency_grid == other.transparency_grid
&& *layers_open == other.layers_open
&& *color_profiles == other.color_profiles
&& *export_settings == other.export_settings
&& *extra == other.extra
&& *slices == other.slices
&& *slices_clip_to_artboard == other.slices_clip_to_artboard
&& *print_setup == other.print_setup
&& *assets == other.assets
&& *svg_scripts == other.svg_scripts
&& match (puppet, &other.puppet) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(a, b) => a.is_none() && b.is_none(),
}
&& *new_art == other.new_art
&& *dimensions == other.dimensions
}
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
/// isn't a number).
pub fn raster_ppi(&self) -> f64 {
+111
View File
@@ -60,12 +60,75 @@ pub enum BlendOrientation {
AlignToPath,
}
/// Distribution of blend steps, as offered by the Blend panel's Type menu.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlendEaseType {
#[default]
In,
Out,
InOut,
OutIn,
}
impl BlendEaseType {
pub fn id(self) -> &'static str {
match self {
Self::In => "easeIn",
Self::Out => "easeOut",
Self::InOut => "easeInOut",
Self::OutIn => "easeOutIn",
}
}
}
/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendEase {
#[serde(default)]
pub kind: BlendEaseType,
#[serde(default, deserialize_with = "blend_ramp")]
pub ramp: f64,
}
impl BlendEase {
pub fn is_linear(&self) -> bool {
self.ramp <= 0.0
}
/// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0.
pub fn is_default(&self) -> bool {
self.kind == BlendEaseType::In && self.ramp == 0.0
}
/// A monotone polynomial distribution, blended with even spacing by Ramp. Public help
/// describes the four distributions, not their numeric curves; these curves are original.
pub fn apply(&self, u: f64) -> f64 {
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 };
let eased = match self.kind {
BlendEaseType::In => u * u,
BlendEaseType::Out => u * (2.0 - u),
BlendEaseType::InOut => u * u * (3.0 - 2.0 * u),
BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u),
BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5),
};
u + r * (eased - u)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendSpec {
#[serde(default)]
pub spacing: BlendSpacing,
#[serde(default)]
pub orientation: BlendOrientation,
/// Acceleration of the steps' shapes and positions along the spine between two keys.
#[serde(default, skip_serializing_if = "BlendEase::is_default")]
pub ease: BlendEase,
/// Independent color acceleration. -100..100, with 0 preserving the even color ramp.
#[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")]
pub color_shift: f64,
/// Replaced spine. `None` = the straight lines between the key objects' centres.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spine: Option<PathData>,
@@ -1757,10 +1820,58 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxe
o
}
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
/// and anything that is not a finite number (NaN, null, text) becomes the default. The
/// commands still refuse such input (`ease_param`).
fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result<f64, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max)))
}
fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
blend_number(d, 0.0, 1.0)
}
fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
blend_number(d, -100.0, 100.0)
}
/// Whether an optional stored color shift has its unchanged default.
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl BlendSpec {
/// Shift the color transition independently of shape placement. The exponential curve is
/// original; the documented behavior specifies the direction, not numeric sample values.
pub fn color_position(&self, u: f64) -> f64 {
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 };
if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of
/// failing the whole document.
#[test]
fn blend_easing_numbers_clamp_when_read() {
use serde::de::value::{Error, F64Deserializer};
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0));
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0));
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0));
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(blend_ramp(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
assert_eq!(blend_color_shift(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
}
}
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
/// real distance from the centre line; an inner edge that would pass the centre stops there.
+2 -4
View File
@@ -1301,10 +1301,8 @@ impl Node {
self.projected(Some(b)).map(|b| b.inflate(o, o))
}
NodeKind::Text(t) => {
// The characters' strokes paint around the glyphs, scaled with the type.
let k = Scaling::factor(t.xf).unwrap_or(1.0);
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
let o = self.appearance.outset().max(chars);
// The characters' strokes paint around the glyphs.
let o = self.appearance.outset().max(t.char_stroke_outset());
self.geometric(symbols).map(|b| b.inflate(o, o))
}
// An image is measured by its frame, without its own fills and strokes.
+31 -1
View File
@@ -104,6 +104,12 @@ pub struct CharStyle {
/// their line line up with it.
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign,
/// Character Direction (Character panel menu, Middle Eastern options): these characters are
/// set left to right or right to left inside their paragraph, each stretch of them a Unicode
/// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives
/// them.
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
pub char_direction: Option<ParaDirection>,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
/// its proportional heights, `vpal`.
@@ -297,6 +303,7 @@ impl Default for CharStyle {
stroke_align: StrokeAlign::Center,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
char_direction: None,
proportional_metrics: false,
tate_chu_yoko: false,
small_caps: None,
@@ -862,7 +869,8 @@ impl From<ParaStyleFile> for ParaStyle {
}
}
/// A paragraph's base direction ([`ParaStyle::direction`]).
/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set
/// in ([`CharStyle::char_direction`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ParaDirection {
@@ -1702,6 +1710,12 @@ impl TextObject {
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
}
}
/// How far the characters' strokes paint past their glyphs, in document space: they scale
/// with the type.
pub fn char_stroke_outset(&self) -> f64 {
let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0);
self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k
}
pub fn transform(&mut self, a: Affine) {
self.xf = a * self.xf;
}
@@ -1906,6 +1920,22 @@ impl crate::Document {
mod tests {
use super::*;
/// Character Direction is saved only when set, and styles written before it existed read as
/// Default.
#[test]
fn character_direction_is_saved_only_when_set() {
let st = CharStyle::default();
assert_eq!(st.char_direction, None);
assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none());
for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] {
let set = CharStyle { char_direction: Some(d), ..CharStyle::default() };
assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().char_direction, None);
}
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
#[test]
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
+19
View File
@@ -127,6 +127,25 @@ fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted");
}
#[test]
fn expand_stroke_region_of_a_round_line_stays_compact() {
use std::f64::consts::PI;
use vectorcraft_geom::{FillRule, PathData, Point, SubPath};
let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false));
let st = stroke(10.0, |s| s.cap = vectorcraft_doc::LineCap::Round);
let region = crate::stroke::outline::outline_region(&path, FillRule::NonZero, &st);
let anchors = region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert!(anchors <= 8, "expand stroke should use few anchors for a round line, got {anchors}");
let area = region.subpaths.iter().map(|s| s.area()).sum::<f64>().abs();
assert!(near(area, 100.0 * 10.0 + PI * 5.0 * 5.0, 1.0), "{area}");
// Extra colinear anchors along the spine must not multiply outline anchors (#3446).
let dense: Vec<Point> = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect();
let dense_path = PathData::single(SubPath::polyline(&dense, false));
let dense_region = crate::stroke::outline::outline_region(&dense_path, FillRule::NonZero, &st);
let dense_anchors = dense_region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
}
#[test]
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
let l = line(0.0, 100.0);
+46 -14
View File
@@ -119,7 +119,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: the selection), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
has_doc,
copy_from
),
@@ -758,11 +758,13 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Sample enabled attributes, refusing object transparency that a text range cannot store.
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
let selected = match ids_param(p, "ids") {
Some(v) => outermost(&s.doc()?.doc, v),
None => selected_roots(s)?,
@@ -776,12 +778,38 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
};
targets.retain(|id| *id != src_id);
let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?);
let range = range.filter(|(id, _, _)| targets.contains(id));
// Character styles cannot represent object transparency. Refuse before even changing
// paint defaults; applying it to the object would also alter the unselected characters.
if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) {
let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal {
return Err(bad(
C,
"object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object",
));
}
}
if !reverse {
picked.to_defaults(s, &attrs.appearance);
}
if targets.is_empty() || attrs.is_empty() {
return Ok(json!({ "ids": [] }));
}
if let Some((id, start, end)) = range {
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let placed = picked.placed(Some(t.local_bounds()));
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
}
super::typecmd::refresh_bounds(t);
Ok(count_changed(d, &before, &[id]))
})?;
return Ok(json!({ "ids": [id.0] }));
}
// When every target comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
@@ -843,6 +871,22 @@ impl Picked {
}
}
/// Copy character attributes and the enabled paint attributes through the same path for
/// whole text objects and highlighted character ranges.
fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) {
if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) {
*st = CharStyle {
fill: std::mem::take(&mut st.fill),
stroke: std::mem::take(&mut st.stroke),
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
..cs.clone()
};
}
run_attrs(st, placed, &a.appearance);
}
/// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its
/// stack instead. Type takes the paints on its characters, and character and paragraph
/// attributes from type.
@@ -862,19 +906,7 @@ impl Picked {
let placed = self.placed(Some(t.local_bounds()));
let text = self.text.as_ref();
for r in &mut t.runs {
if let Some((cs, _)) = text.filter(|_| a.character) {
// The character attributes; the paints follow the appearance attributes.
let st = &mut r.style;
*st = CharStyle {
fill: std::mem::take(&mut st.fill),
stroke: std::mem::take(&mut st.stroke),
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
..cs.clone()
};
}
run_attrs(&mut r.style, &placed, ap);
self.run(&mut r.style, &placed, a);
}
if let Some((_, para)) = text.filter(|_| a.paragraph) {
// The source's first paragraph's attributes, on every paragraph.
+29 -12
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder",
[],
None,
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
has_selection_or_ids,
sb_merge
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder Regions",
[],
None,
"{ids?|id?, openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
"{ids?|id?, gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
has_selection_or_ids,
sb_regions
),
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket",
[],
None,
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}",
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}",
has_doc,
lp_fill
),
@@ -236,6 +236,12 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
}
}
/// Gap Detection length in points, bounded like Live Paint's custom gap size.
fn gap_param(p: &Value) -> f64 {
let gap = f64_or(p, "gap", 0.0);
if gap.is_finite() { gap.clamp(0.0, po::SHAPE_BUILDER_MAX_GAP) } else { 0.0 }
}
fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shapeBuilder.merge";
let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?;
@@ -262,7 +268,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::new(d, &roots, erase, open_filled_as_closed);
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p));
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
@@ -280,7 +286,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
// Edges come back from the planar map within a few hundredths of the outlines they follow.
let cut_tol = step * 0.2;
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::new(&map.shapes);
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
// The merged shape takes the look of the front-most object over the first region, or (for an
// area only lines enclose) of the front-most object and the current fill.
@@ -378,7 +384,7 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let d = &s.doc()?.doc;
let (map, leaves) = BuilderMap::new(d, &roots, true, bool_or(p, "openFilledAsClosed", true));
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p));
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map
.regions
@@ -764,13 +770,24 @@ fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
}
g
}
None => match roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face_at(n, pt).is_some())) {
Some(g) => g,
None => make_live(d, sel, &roots)?.0,
},
None => {
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
};
match (live_at(b::visible_face_at), live_at(face_at)) {
(Some(g), _) => g,
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
// and does not merge the objects.
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
(None, None) => make_live(d, sel, &roots)?.0,
}
}
};
let fid =
d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?;
let fid = d
.node(gid)
.and_then(|n| b::visible_face_at(n, pt))
.map(|f| f.id)
.ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?;
d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone());
Ok((gid, fid))
})?;
+49 -7
View File
@@ -10,8 +10,8 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::live::{
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance,
PreserveShape, Spine,
self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions,
GradientMesh, MeshAppearance, PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Blend"],
Some("Cmd+Alt+B"),
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
has_doc,
blend_make
),
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Options…",
["Object", "Blend"],
None,
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}",
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}",
always,
blend_options
),
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Info",
[],
None,
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
always,
blend_info
),
@@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
}
}
/// Blend panel Type and Ramp, validated before any document mutation.
fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result<BlendEase> {
let kind = match p.get("easingType").filter(|v| !v.is_null()) {
None => current.kind,
Some(v) => match v.as_str() {
Some("easeIn") => BlendEaseType::In,
Some("easeOut") => BlendEaseType::Out,
Some("easeInOut") => BlendEaseType::InOut,
Some("easeOutIn") => BlendEaseType::OutIn,
_ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")),
},
};
Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 })
}
/// Bounded percentage input; missing or null preserves the current value.
fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result<f64> {
match p.get(key).filter(|v| !v.is_null()) {
None => Ok(current),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && (min..=100.0).contains(n))
.ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))),
}
}
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
const C: &str = "object.blend.make";
@@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
_ => Default::default(),
};
let ease = ease_param(p, C, ease0)?;
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
let id = s.edit("Make Blend", |d, sel| {
wrap(d, sel, &roots, |id, nodes| {
let (children, starts) = merge_keys(nodes, &starts);
@@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
n.knockout = vectorcraft_doc::Knockout::On;
n
});
let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() };
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
extend_spine(&mut spec, old, old_spec, &children);
}
@@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.options";
let Some((_, cur, _)) = selected_spec(s) else {
// Nothing to set them on: the options new blends start with.
if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
return Err(bad(C, "select a blend to set easing or color shift"));
}
// Nothing to set them on: the options new blends start with (easing is per blend).
let d = s.prefs.blend_options.unwrap_or_default();
let spacing = spacing_param(p, C, d.spacing)?;
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
@@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
let ease = ease_param(p, C, cur.ease)?;
let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?;
edit_blends(s, "Blend Options", |_, spec| {
spec.spacing = spacing;
spec.orientation = orientation;
spec.ease = ease;
spec.color_shift = color_shift;
})
}
@@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
let mut v = blend_options_json(spec.spacing, spec.orientation);
v["target"] = json!("blend");
v["id"] = json!(id.0);
v["easingType"] = json!(spec.ease.kind.id());
v["ramp"] = json!(spec.ease.ramp * 100.0);
v["colorShift"] = json!(spec.color_shift);
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
+6 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -327,6 +327,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"direction",
"leadingModel",
"charAlign",
"charDirection",
"proportionalMetrics",
"tateChuYoko",
"noBreak",
@@ -350,6 +351,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
};
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
let char_align = super::textedit::char_align_param(p, C)?;
let char_direction = super::textedit::char_direction_param(p, C)?;
let digits = super::textedit::digits_param(p, C)?;
let font_axes = super::textedit::font_axes_param(p, C)?;
let mojikumi = match p.get("mojikumi") {
@@ -460,6 +462,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = char_align {
st.char_align = v;
}
if let Some(v) = char_direction {
st.char_direction = v;
}
if let Some(v) = digits {
st.digits = v;
}
+1 -1
View File
@@ -491,7 +491,7 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool {
let own = |w: &vectorcraft_color::Swatch| pattern_name(&w.paint).is_some_and(|p| p == old);
let mut holders = d.swatches_iter().filter(|w| w.name == new).peekable();
let only_own = holders.peek().is_some() && holders.all(own);
d.swatch_name_taken(new) && !(only_own && !d.swatches_iter().any(|w| w.name == old && own(w)))
d.swatch_name_taken(new) && (!only_own || d.swatches_iter().any(|w| w.name == old && own(w)))
}
/// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the
+1 -1
View File
@@ -123,7 +123,7 @@ fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, St
}
if super::effectcmd::texture_load_effect(&e.id)
&& let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE)
&& !t.as_str().is_some_and(|t| effects::pixel::texture(t).is_some())
&& t.as_str().is_none_or(|t| effects::pixel::texture(t).is_none())
{
return Err(refuse("`textureImage` must be a texture as effect.list gives it".into()));
}
+54 -11
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces, except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches]}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], note?: set when a colour kept a model other than its new colour's (see model)}",
has_doc,
apply
),
@@ -268,17 +268,19 @@ struct ColorMap {
links: Vec<Option<Link>>,
keys: HashMap<ColorKey, usize>,
hexes: HashMap<String, usize>,
/// Set when a colour took a new colour of another model in its own model.
converted: std::cell::Cell<bool>,
}
impl ColorMap {
/// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none).
fn new(rows: &[Row], method: Method, links: &[Option<Link>]) -> Self {
let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new() };
fn new(rows: &[Row], method: Method, own_model: bool, links: &[Option<Link>]) -> Self {
let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() };
for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) {
let Some(to) = r.new_color() else { continue };
let i = out.rows.len();
let colors: Vec<Color> = r.from.iter().map(Match::color).collect();
out.rows.push(RowMap::new(method, &colors, to));
out.rows.push(RowMap::new(method, &colors, to).with_own_model(own_model));
out.links.push(links.get(n).cloned().flatten());
for m in &r.from {
match m {
@@ -296,7 +298,11 @@ impl ColorMap {
}
fn map(&self, c: Color) -> Color {
self.row_of(c).and_then(|i| self.rows.get(i)).map_or(c, |r| r.map(c))
let Some(r) = self.row_of(c).and_then(|i| self.rows.get(i)) else { return c };
if r.converts(c) {
self.converted.set(true);
}
r.map(c)
}
/// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot
@@ -315,7 +321,12 @@ impl ColorMap {
return changed;
}
(Some(l), Some(t)) => l.color.tinted(t),
_ => row.map(*c),
_ => {
if row.converts(*c) {
self.converted.set(true);
}
row.map(*c)
}
};
if new == *c {
return false;
@@ -351,17 +362,42 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
let scope = Scope { fill: true, stroke: true, ..Scope::of(p) };
let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" };
// `model`: keep each colour's model (default), take the new colour's own, or the document's.
// A given `model` must be a string; only a missing one means "keep".
let model = match p.get("model") {
None => "keep",
Some(Value::String(m)) => m.as_str(),
Some(other) => return Err(bad(C, format!("bad model {other} (keep, new or document)"))),
};
let mut doc_model = None;
let own_model = match model {
"keep" => false,
"new" => true,
"document" => {
let m = s.doc()?.doc.color_mode.model();
doc_model = Some(m);
for r in &mut rows {
r.to = r.to.map(|c| c.in_model(m));
}
true
}
other => return Err(bad(C, format!("unknown model `{other}` (keep, new or document)"))),
};
// The colours a colour group takes follow the model too (`document` converts them).
let in_doc_model = |c: Color| doc_model.map_or(c, |m| c.in_model(m));
let new: Vec<Color> = match p.get("groupColors").and_then(Value::as_array) {
Some(cs) => cs
.iter()
.map(|v| {
color_value(v).map(|c| palette.as_ref().map_or(c, |pl| pl.nearest(c))).ok_or_else(|| bad(C, format!("bad colour {v} in groupColors")))
color_value(v)
.map(|c| in_doc_model(palette.as_ref().map_or(c, |pl| pl.nearest(c))))
.ok_or_else(|| bad(C, format!("bad colour {v} in groupColors")))
})
.collect::<Result<_>>()?,
None => rows.iter().filter_map(Row::new_color).collect(),
};
let rename = str_param(p, "rename");
let (changed, edit, added) = s.edit(label, |d, _| {
let (changed, edit, added, converted) = s.edit(label, |d, _| {
// The rows' library swatches join the document (one it has with that name and colour is
// used as it is).
let mut added = vec![];
@@ -382,17 +418,24 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
Some(Link { name, global: w.global, color })
})
.collect();
let map = ColorMap::new(&rows, method, &links);
let map = ColorMap::new(&rows, method, own_model, &links);
let f = |c: Color| map.map(c);
let lf = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| map.map_link(c, link, tint);
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
Ok((changed, edit, added))
Ok((changed, edit, added, map.converted.get()))
})?;
if let Some(e) = edit {
e.defaults(s);
}
Ok(json!({ "changed": changed, "swatches": added }))
let mut out = json!({ "changed": changed, "swatches": added });
if converted {
out["note"] = json!(
"some colours kept their own colour model, so the new colour was converted into it and may look different; \
pass model \"new\" (the new colour's model) or \"document\" (the document's colour mode) to change the model"
);
}
Ok(out)
}
fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
+20 -1
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -256,6 +256,7 @@ pub(crate) struct CharChange {
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
char_direction: Option<Option<vectorcraft_doc::ParaDirection>>,
proportional_metrics: Option<bool>,
tate_chu_yoko: Option<bool>,
no_break: Option<bool>,
@@ -263,6 +264,19 @@ pub(crate) struct CharChange {
font_axes: Option<AxisChange>,
}
/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None`
/// inside: Default).
pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_doc::ParaDirection>>> {
use vectorcraft_doc::ParaDirection;
let Some(v) = p.get("charDirection").filter(|v| !v.is_null()) else { return Ok(None) };
Ok(Some(match v.as_str() {
Some("default") => None,
Some("leftToRight") => Some(ParaDirection::LeftToRight),
Some("rightToLeft") => Some(ParaDirection::RightToLeft),
_ => return Err(bad(cmd, "`charDirection` must be \"default\", \"leftToRight\" or \"rightToLeft\"")),
}))
}
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::Digits>> {
use vectorcraft_doc::Digits;
@@ -416,6 +430,7 @@ impl CharChange {
position,
small_caps,
char_align: char_align_param(p, cmd)?,
char_direction: char_direction_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
tate_chu_yoko: flag("tateChuYoko"),
no_break: flag("noBreak"),
@@ -459,6 +474,7 @@ impl CharChange {
&& self.position.is_none()
&& self.small_caps.is_none()
&& self.char_align.is_none()
&& self.char_direction.is_none()
&& self.proportional_metrics.is_none()
&& self.tate_chu_yoko.is_none()
&& self.no_break.is_none()
@@ -554,6 +570,9 @@ impl CharChange {
if let Some(v) = self.char_align {
st.char_align = v;
}
if let Some(v) = self.char_direction {
st.char_direction = v;
}
if let Some(v) = self.proportional_metrics {
st.proportional_metrics = v;
}
+1
View File
@@ -155,6 +155,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("position", json!(v.position)),
("small_caps", json!(v.small_caps)),
("charAlign", json!(v.char_align)),
("charDirection", json!(v.char_direction)),
("proportionalMetrics", json!(v.proportional_metrics)),
("tateChuYoko", json!(v.tate_chu_yoko)),
("noBreak", json!(v.no_break)),
+41
View File
@@ -1599,6 +1599,47 @@ mod area_tests {
}
}
/// Character Direction (Character panel menu, Middle Eastern options): a character attribute
/// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when
/// set and kept by a native save.
#[test]
fn character_direction_is_a_character_attribute_saved_only_when_set() {
use vectorcraft_doc::ParaDirection;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::<Vec<_>>();
assert_eq!(runs(&s)[0].style.char_direction, None);
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] {
s.execute("text.setFormat", &json!({"charDirection": key})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.char_direction == want));
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key));
}
let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string();
assert!(err.contains("`charDirection` must be"), "{err}");
// Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes).
s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap();
assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none()));
s.execute("edit.redo", &json!({})).unwrap();
// Saved and reopened as it was.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect();
assert_eq!(reopened, set(&s));
// A character style captured from the characters carries it, Default included.
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight");
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null));
}
/// Proportional Metrics (#966): a character attribute, saved only when on.
#[test]
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
+76
View File
@@ -153,3 +153,79 @@ fn start_points_round_trip_and_old_blends_load() {
assert!(old.starts.is_empty() && old.start(0).is_none());
assert!(serde_json::to_value(&old).unwrap().get("starts").is_none());
}
#[test]
fn easing_is_set_validated_saved_and_old_blends_are_even() {
let mut s = session();
assert!(s.execute("object.blend.options", &json!({"ramp": 50})).is_err());
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeIn", "ramp": 40})).unwrap());
assert_eq!(spec(&s, g).1.ease.ramp, 0.4);
sel(&mut s, &[g]);
for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] {
s.execute("object.blend.options", &json!({"easingType": kind, "colorShift": -50})).unwrap();
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!((&info["easingType"], &info["ramp"], &info["colorShift"]), (&json!(kind), &json!(40.0), &json!(-50.0)));
}
let before = spec(&s, g).1;
let depth = s.doc().unwrap().history.undo.len();
for bad in [json!({"ramp": 101}), json!({"ramp": -1}), json!({"colorShift": "x"}), json!({"colorShift": 1e308}), json!({"easingType": "unknown"})]
{
assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}");
assert_eq!(spec(&s, g).1, before);
assert_eq!(s.doc().unwrap().history.undo.len(), depth);
}
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(&back.layers, &d.layers);
s.execute("object.blend.options", &json!({"ramp": 0})).unwrap();
let zero = spec(&s, g).1;
let back: BlendSpec = serde_json::from_value(serde_json::to_value(&zero).unwrap()).unwrap();
assert_eq!(back.ease, zero.ease, "a chosen Type survives at Ramp 0");
let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap();
assert!(old.ease.is_linear() && old.color_shift == 0.0);
let v = serde_json::to_value(&old).unwrap();
assert!(v.get("ease").is_none() && v.get("colorShift").is_none());
}
#[test]
fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeOut", "ramp": 40, "colorShift": -50})).unwrap());
let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let blend_of = |doc: &vectorcraft_doc::Document| match doc.node(g).unwrap().kind.clone() {
NodeKind::Blend { spec, .. } => *spec,
_ => panic!("not a blend"),
};
for (ramp, shift, want) in [("12.5", "-9000.0", (1.0, -100.0)), ("-3.0", "250.0", (0.0, 100.0)), ("null", "\"NaN\"", (0.0, 0.0))] {
let t = text.replacen("\"ramp\":0.4", &format!("\"ramp\":{ramp}"), 1).replacen("\"colorShift\":-50.0", &format!("\"colorShift\":{shift}"), 1);
assert!(t != text && t.contains("\"ramp\":") && t.contains(shift), "the saved blend carries ramp and colorShift");
let doc = vectorcraft_format::load(t.as_bytes()).expect("one bad blend number must not fail the document");
let spec = blend_of(&doc);
assert_eq!((spec.ease.ramp, spec.color_shift), want, "{ramp} / {shift}");
}
// The commands still refuse the same values and leave the blend alone.
sel(&mut s, &[g]);
let before = spec(&s, g).1;
for bad in [json!({"ramp": 1250}), json!({"ramp": -3}), json!({"colorShift": 250}), json!({"colorShift": "NaN"}), json!({"colorShift": 1e308})] {
assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}");
assert_eq!(spec(&s, g).1, before);
}
}
#[test]
fn native_blend_easing_values_clamp_and_non_finite_become_default() {
for (bad, ramp, shift) in [
(json!({"ease": {"ramp": 1e308}}), 1.0, 0.0),
(json!({"ease": {"ramp": -0.1}}), 0.0, 0.0),
(json!({"colorShift": 101}), 0.0, 100.0),
(json!({"colorShift": -101}), 0.0, -100.0),
(json!({"ease": {"ramp": null}, "colorShift": "NaN"}), 0.0, 0.0),
] {
let spec: BlendSpec = serde_json::from_value(bad.clone()).unwrap_or_else(|e| panic!("{bad}: {e}"));
assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}");
}
}
+75
View File
@@ -358,6 +358,55 @@ fn shape_builder_fills_an_area_lines_enclose() {
assert_eq!(ids(&r).len(), 4, "the triangle + the three lines, which only bound it");
}
/// Two diagonals and a crossbar that stops about 3 pt short of each: the triangle they bound.
fn lines_with_gaps(s: &mut Session) {
let l1 = line(s, (0.0, 0.0), (100.0, 100.0));
let l2 = line(s, (100.0, 0.0), (0.0, 100.0));
let l3 = line(s, (24.0, 80.0), (76.0, 80.0));
s.execute("select.set", &json!({"ids": [l1.0, l2.0, l3.0]})).unwrap();
}
#[test]
fn shape_builder_gap_detection_closes_small_gaps_between_lines() {
let mut s = session();
lines_with_gaps(&mut s);
let regions = |s: &mut Session, p: Value| s.execute("shapeBuilder.regions", &p).unwrap()["regions"].as_array().unwrap().len();
assert_eq!(regions(&mut s, json!({})), 0, "off by default");
assert_eq!(regions(&mut s, json!({"gap": 2})), 0, "the gap is longer");
assert_eq!(regions(&mut s, json!({"gap": 3})), 1);
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]]})).is_err(), "no gap, no region");
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]], "gap": 3})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
let a = area(&s, merged);
assert!(a > 850.0 && a < 900.0, "the triangle and its two bridges: {a}");
assert_eq!(ids(&r).len(), 4, "the bridges are no art: the merged shape and the three lines");
assert_eq!(open_paths(&s, &ids(&r)).len(), 3);
}
#[test]
fn shape_builder_tool_gap_detection_option() {
let mut s = session();
lines_with_gaps(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
assert_eq!((s.tool_options()["gapDetection"].clone(), s.tool_options()["gapLength"].clone()), (json!(false), json!(3.0)));
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 70.0), v).unwrap();
};
click(&mut s);
assert_eq!(layer_children(&s), 3, "Gap Detection is off");
s.set_tool_option_cmd(&json!({"values": {"gapDetection": true, "gapLength": 6.0}})).unwrap();
click(&mut s);
assert_eq!(layer_children(&s), 4, "the triangle");
// The options are kept when another tool is chosen.
s.select_tool("selection", v).unwrap();
assert_eq!(
(s.tool_options_of("shapeBuilder")["gapDetection"].clone(), s.tool_options_of("shapeBuilder")["gapLength"].clone()),
(json!(true), json!(6.0))
);
}
#[test]
fn shape_builder_closes_filled_open_paths() {
let mut s = session();
@@ -689,6 +738,32 @@ fn live_paint_stroke_edge_skips_hidden_edges() {
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50]})).is_err());
}
#[test]
fn live_paint_fill_skips_hidden_faces() {
let mut s = session();
let g = live(&mut s);
// The face from x=50 to x=100 is hidden on its own; the group stays visible.
let hidden = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(75.0, 50.0)).unwrap().id;
s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap();
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
let children = node(&s, g).children().unwrap().len();
// livePaint.fill at a point on the hidden face returns an error and fills no face, with `group`
// and with the group in `ids`, and it does not rebuild the group.
for p in [json!({"group": g.0, "point": [75, 50], "color": "#00ff00"}), json!({"ids": [g.0], "point": [75, 50], "color": "#00ff00"})] {
assert!(s.execute("livePaint.fill", &p).is_err(), "{p}");
let face = node(&s, hidden);
assert!(!face.visible && face.appearance.fill_paint() != green, "{p}");
assert_eq!(node(&s, g).children().unwrap().len(), children, "{p}");
}
// Given another Live Paint group with a face at that point as well, that face is filled.
let other = rect(&mut s, 60.0, 40.0, 20.0, 20.0);
let other = NodeId(s.execute("livePaint.make", &json!({"ids": [other.0]})).unwrap()["id"].as_u64().unwrap());
let r = s.execute("livePaint.fill", &json!({"ids": [g.0, other.0], "point": [75, 50], "color": "#00ff00"})).unwrap();
assert_eq!(r["group"], other.0);
assert_eq!(node(&s, NodeId(r["face"].as_u64().unwrap())).appearance.fill_paint(), green);
assert!(!node(&s, hidden).visible);
}
#[test]
fn live_paint_release_and_expand() {
let mut s = session();
+159
View File
@@ -509,3 +509,162 @@ fn type_takes_the_stroke_options_it_has() {
assert_eq!((st.stroke.color().unwrap().to_hex(), st.stroke_width, st.stroke_cap), ("#0000ff".into(), 5.0, LineCap::Round));
assert!(st.stroke_dash.is_none(), "the dash pattern isn't applied");
}
/// Unsupported object transparency must fail atomically; opting out still samples the range.
#[test]
fn eyedropper_text_range_rejects_object_transparency_without_changes() {
for transparency in [json!({"opacity": 50}), json!({"blend": "multiply"})] {
let mut s = session();
s.prefs.placeholder_text = false;
let src = rect(&mut s, 300.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
s.execute("transparency.set", &transparency).unwrap();
let view = ViewInfo::default();
s.select_tool("type", view).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
}
s.tool_text("MAMA NA KAZI", view).unwrap();
s.set_tool_option("select", &json!({"start": 0, "end": 4}));
s.select_tool("eyedropper", view).unwrap();
s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let paint = s.paint.clone();
let undo = s.doc().unwrap().history.undo.len();
let selection = s.tool_options();
let err = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err();
assert!(err.to_string().contains("object transparency"), "{err}");
assert_eq!(s.doc().unwrap().doc, doc);
assert_eq!(s.paint, paint);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(s.tool_options(), selection);
// Disabling transparency still copies the paint to just the highlighted characters.
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap();
let dst = s.doc().unwrap().selection.objects[0];
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert_eq!(t.runs[0].text, "MAMA");
assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00");
assert_eq!(t.runs[1].text, " NA KAZI");
let NodeKind::Text(before) = &doc.node(dst).unwrap().kind else { panic!() };
assert_eq!(t.runs[1].style, before.runs.last().unwrap().style);
}
}
/// An opaque sample cannot replace only a range's inherited object transparency.
#[test]
fn eyedropper_text_range_rejects_target_transparency_without_changes() {
for transparency in [json!({"opacity": 50}), json!({"blend": "multiply"})] {
let mut s = session();
s.prefs.placeholder_text = false;
let src = rect(&mut s, 300.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
let view = ViewInfo::default();
s.select_tool("type", view).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
}
s.tool_text("MAMA NA KAZI", view).unwrap();
let dst = s.doc().unwrap().selection.objects[0];
s.execute("transparency.set", &transparency).unwrap();
s.set_tool_option("select", &json!({"start": 0, "end": 4}));
s.select_tool("eyedropper", view).unwrap();
s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap();
let before = s.doc().unwrap().doc.clone();
let paint = s.paint.clone();
let undo = s.doc().unwrap().history.undo.len();
let selection = s.tool_options();
let err = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err();
assert!(err.to_string().contains("object transparency"), "{err}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.paint, paint);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(s.tool_options(), selection);
// An explicit opt-out samples the range while retaining the object's transparency.
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap();
let n = node(&s, dst);
assert_eq!((n.opacity, n.blend), (before.node(dst).unwrap().opacity, before.node(dst).unwrap().blend));
let NodeKind::Text(t) = n.kind else { panic!("not text") };
assert_eq!(t.runs[0].text, "MAMA");
assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00");
let NodeKind::Text(old) = &before.node(dst).unwrap().kind else { panic!("not text") };
assert_eq!(t.runs[1].text, " NA KAZI");
assert_eq!(t.runs[1].style, old.runs.last().unwrap().style);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
}
}
/// Choosing the Eyedropper from highlighted type must preserve the character target.
#[test]
fn eyedropper_styles_only_highlighted_characters() {
let mut s = session();
s.prefs.placeholder_text = false;
let src = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Source"})).unwrap());
s.execute("text.setStyle", &json!({"font": "Inter", "size": 30, "fill": "#00ff00"})).unwrap();
let view = ViewInfo::default();
s.select_tool("type", view).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
}
let dst = s.doc().unwrap().selection.objects[0];
s.tool_text("MAMA NA KAZI", view).unwrap();
s.set_tool_option("select", &json!({"start": 0, "end": 4}));
let before = match node(&s, dst).kind {
NodeKind::Text(t) => t.first_style(),
_ => panic!(),
};
s.select_tool("eyedropper", view).unwrap();
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert_eq!(t.runs.len(), 2, "only MAMA takes the sampled style");
assert_eq!(t.runs[0].text, "MAMA");
assert_eq!(t.runs[0].style.size, 30.0);
assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00");
assert_eq!(t.runs[1].text, " NA KAZI");
assert_eq!(t.runs[1].style, before);
s.execute("edit.undo", &json!({})).unwrap();
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert_eq!(t.plain_text(), "MAMA NA KAZI", "undo keeps the separate typing step");
assert_eq!(t.runs[0].style, before);
s.execute("edit.redo", &json!({})).unwrap();
s.select_tool("type", view).unwrap();
assert_eq!(s.tool_options()["editing"], dst.0);
assert_eq!((s.tool_options()["start"].as_u64(), s.tool_options()["end"].as_u64()), (Some(0), Some(4)));
}
/// Color-only sampling preserves multibyte ranges and clears stale targets on deselection.
#[test]
fn eyedropper_shift_color_preserves_other_characters_and_clears_on_deselect() {
let mut s = session();
s.prefs.placeholder_text = false;
let src = rect(&mut s, 300.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap();
let view = ViewInfo::default();
s.select_tool("type", view).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
}
let dst = s.doc().unwrap().selection.objects[0];
s.tool_text("Aé中Z", view).unwrap();
s.set_tool_option("select", &json!({"start": 1, "end": 6}));
let NodeKind::Text(before) = node(&s, dst).kind else { panic!() };
click(&mut s, 325.0, 35.0, Mods { shift: true, ..Default::default() });
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["A", "é中", "Z"]);
assert_eq!(t.runs[0].style, before.first_style());
assert_eq!(t.runs[2].style, before.first_style());
assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#00ff00");
assert_eq!(t.runs[1].style.size, before.first_style().size);
assert!(s.overlays(view).iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { .. })), "the highlighted range remains visible");
s.execute("select.none", &json!({})).unwrap();
assert!(s.tool_options().get("editing").is_none_or(Value::is_null));
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert!(
t.runs.iter().all(|r| r.style.fill.color().unwrap().to_hex() == "#00ff00"),
"after deselecting, ordinary object sampling styles the whole object"
);
}
+51
View File
@@ -369,3 +369,54 @@ fn recolor_to_a_library_spot_colour_links_it_and_adds_the_swatch() {
let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "limitTo": lib}));
assert_eq!(r["swatches"], json!(["Leaf"]));
}
#[test]
fn model_param_chooses_the_model_of_the_new_colour() {
// RGB art in a CMYK document: by default a CMYK replacement comes out RGB (#2170).
let mut s = session();
run(&mut s, "file.new", json!({"width": 400, "height": 200, "colorMode": "cmyk"}));
let art = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 15, "height": 15}))["id"].as_u64().unwrap());
run(&mut s, "paint.setFill", json!({"ids": [art.0], "color": "#1a1a1a", "keepModel": true}));
assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Rgb);
select_all(&mut s);
let map = json!({"#1a1a1a": {"c": 0, "m": 0, "y": 0, "k": 100}});
let r = run(&mut s, "recolor.apply", json!({"map": map}));
assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Rgb, "kept RGB");
assert!(r["note"].as_str().is_some_and(|n| n.contains("model")), "warns the model was kept: {r}");
run(&mut s, "edit.undo", json!({}));
let r = run(&mut s, "recolor.apply", json!({"map": map, "model": "new"}));
assert_eq!(fill(&s, art), Color::cmyk(0.0, 0.0, 0.0, 1.0));
assert!(r.get("note").is_none(), "no note when the model follows the new colour: {r}");
run(&mut s, "edit.undo", json!({}));
// "document" converts a new RGB colour into the document's CMYK.
run(&mut s, "recolor.apply", json!({"map": {"#1a1a1a": "#ff0000"}, "model": "document"}));
assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Cmyk);
assert!(s.execute("recolor.apply", &json!({"map": map, "model": "nope"})).is_err());
}
#[test]
fn model_param_must_be_a_string_when_given() {
let mut s = session();
let art = rect(&mut s, json!("#1a1a1a"));
select_all(&mut s);
for bad in [json!(42), json!(null), json!(true)] {
assert!(s.execute("recolor.apply", &json!({"map": {"#1a1a1a": "#ff0000"}, "model": bad})).is_err(), "{bad}");
}
assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed");
}
#[test]
fn document_model_converts_the_colours_of_a_group_too() {
let mut s = session();
run(&mut s, "file.new", json!({"width": 400, "height": 200, "colorMode": "cmyk"}));
run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["cmyk 0 0 0 50"]}));
let group = |s: &Session| -> Vec<Color> {
let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap();
g.swatches.iter().filter_map(|w| w.paint.color()).collect()
};
// Group-only edit from the rows, and with explicit group colours.
run(&mut s, "recolor.apply", json!({"map": {"cmyk 0 0 0 50": "#ff0000"}, "group": "Theme", "model": "document"}));
assert!(group(&s).iter().all(|c| c.model() == vectorcraft_color::cms::Model::Cmyk), "{:?}", group(&s));
run(&mut s, "recolor.apply", json!({"map": [], "group": "Theme", "groupColors": ["#00ff00"], "model": "document"}));
assert!(group(&s).iter().all(|c| c.model() == vectorcraft_color::cms::Model::Cmyk), "{:?}", group(&s));
}
+11 -2
View File
@@ -92,10 +92,19 @@ impl Session {
if self.tool.id() == id {
return Ok(());
}
// Sampling type keeps its highlighted characters as the target. Capture before the
// Type tool deactivates (which commits typing and clears its editing state).
let is_type = |id: &str| matches!(id, "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath");
let text_selection = ((id == "eyedropper" && is_type(self.tool.id())) || (self.tool.id() == "eyedropper" && is_type(id)))
.then(|| self.tool.options())
.filter(|o| o.get("editing").is_some_and(|v| !v.is_null()) && o.get("start") != o.get("end"));
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
self.apply_actions(acts)?;
self.keep_tool_settings();
self.tool = self.make_tool(id);
if let Some(selection) = text_selection {
self.tool.set_option("textSelection", &selection);
}
if vectorcraft_tools::catalog::is_selection_tool(id) {
self.last_selection_tool = Some(self.tool.id());
}
@@ -512,9 +521,9 @@ impl Session {
}
/// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]):
/// the Type tool stops editing text that is no longer selected. Only while it edits text.
/// the Type tool and the Eyedropper clear a character range whose text is no longer selected.
pub(crate) fn after_command(&mut self) {
if self.in_tool_actions || !self.tool.wants_text() {
if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") {
return;
}
let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx));
+55
View File
@@ -472,6 +472,8 @@ fn structured_junk() {
("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})),
("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})),
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})),
("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})),
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
@@ -489,6 +491,21 @@ fn structured_junk() {
("symbol.options", json!({"name": "Dot", "nineSlice": true, "nineSliceGuides": [1e308, -1e308, 0, 0]})),
("symbol.options", json!({"nineSlice": 7, "nineSliceGuides": []})),
("symbol.options", json!({"nineSlice": false, "nineSliceGuides": null})),
// Shape Builder Gap Detection: the `gap` param and the tool options.
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": 1e308})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80], [120, 90]], "gap": -1e308})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": f64::MAX})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": "NaN"})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": "1e999"})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": null})),
("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": [1, 2]})),
("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": {"a": 1}, "erase": true})),
("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": 1e-300, "erase": true, "tolerance": 1e308})),
("shapeBuilder.regions", json!({"gap": 1e308})),
("shapeBuilder.regions", json!({"ids": [0, 1, 2, 3], "gap": -1})),
("shapeBuilder.regions", json!({"ids": [2, 3], "gap": "x"})),
("shapeBuilder.regions", json!({"gap": true})),
("shapeBuilder.regions", json!({"gap": 12, "ids": [u64::MAX]})),
];
let mut failures = vec![];
for fx in Fixture::ALL {
@@ -501,6 +518,44 @@ fn structured_junk() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// Shape Builder Tool Options (Gap Detection) with junk values: never a panic, and the tool still
/// answers with options and works on a drag afterwards.
#[test]
fn shape_builder_options_junk() {
let cases = [
json!({"values": {"gapDetection": true, "gapLength": 1e308}}),
json!({"values": {"gapDetection": "yes", "gapLength": -1e308}}),
json!({"values": {"gapDetection": null, "gapLength": "NaN"}}),
json!({"values": {"gapDetection": [true], "gapLength": [1, 2]}}),
json!({"values": {"gapDetection": true, "gapLength": "1e999"}}),
json!({"values": {"gapLength": {"a": 1}, "bogus": 1}}),
json!({"key": "gapLength", "value": f64::MAX}),
json!({"key": "gapLength", "value": f64::MIN_POSITIVE}),
json!({"key": "gapDetection"}),
json!({"key": "gapDetection", "value": 1}),
json!({"values": [1, 2]}),
json!({"values": "x"}),
];
let mut failures = vec![];
for fx in Fixture::ALL {
for p in &cases {
let mut s = fx.session();
let r = catch_quiet(|| {
let _ = s.select_tool("shapeBuilder", Default::default());
let _ = s.set_tool_option_cmd(p);
let _ = s.set_tool_option_cmd(&json!({"values": {"gapDetection": true}}));
no_panic(s.execute("shapeBuilder.regions", &json!({})))
});
if let Err(msg) = r {
failures.push(format!("PANIC {p} [{fx:?}]: {msg}"));
} else if let Err(e) = check_session(&s) {
failures.push(format!("{p} [{fx:?}]: {e}"));
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// Area Type Options with junk fit values, on selected area type (the fixtures have none), with
/// text that overflows: never a panic, the document stays sound, and editing still works.
#[test]
+2 -2
View File
@@ -21,8 +21,8 @@ pub use edit::{
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use planar::{
BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint,
live_paint_with_gaps, shape_builder,
BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point,
live_paint, live_paint_with_gaps, shape_builder, shape_builder_with_gap,
};
/// Errors from fallible operations.
+71
View File
@@ -8,11 +8,30 @@ pub use kurbo::{Cap, Join};
use linesweeper::topology::{ContourIdx, Contours};
use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, contours_to_path, fill_bezpath, normalize_bez};
use crate::edit::remove_redundant_points;
/// Flattening/fit tolerance handed to kurbo's stroker.
const STROKE_TOL: f64 = 1e-3;
/// Drop collinear and coincident spine vertices before stroking (#3446). Uses `tol` only for
/// redundancy; does not simplify or refit the spine.
fn compact_stroke_spine(bp: &BezPath, tol: f64) -> BezPath {
if bp.elements().is_empty() {
return BezPath::new();
}
let tol = if tol.is_finite() && tol > 0.0 { tol } else { STROKE_TOL };
remove_redundant_points(&PathData::from_bezpath(bp), tol).to_bezpath()
}
fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath {
let spine = compact_stroke_spine(bp, STROKE_TOL);
let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0));
close_all(&kurbo::stroke(spine.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
}
/// Like [`stroke_bez`] but without [`compact_stroke_spine`] (regression baseline in unit tests).
#[cfg(test)]
fn stroke_bez_without_spine_compact(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath {
let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0));
close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
}
@@ -217,4 +236,56 @@ mod tests {
assert_eq!(out.subpaths.len(), 1);
assert!(after > before, "{before} → {after}");
}
fn outline_max_deviation(a: &BezPath, b: &BezPath) -> f64 {
use kurbo::ParamCurve;
use vectorcraft_geom::Point;
let pa = PathData::from_bezpath(a);
let pb = PathData::from_bezpath(b);
let mut worst: f64 = 0.0;
for (src, dst) in [(a, &pb), (b, &pa)] {
for seg in src.segments() {
for i in 0..=12 {
let p = seg.eval(i as f64 / 12.0);
let pt = Point::new(p.x, p.y);
worst = worst.max(dst.nearest(pt).unwrap().4);
}
}
}
worst
}
#[test]
fn compact_stroke_spine_drops_colinear_points_without_moving_curved_outlines() {
let width = 10.0;
let cap = Cap::Round;
let join = Join::Round;
let miter = 4.0;
// Dense colinear spine: compact outline should match the uncompacted one closely.
let dense: Vec<vectorcraft_geom::Point> = (0..=40).map(|i| vectorcraft_geom::Point::new(i as f64 * 2.5, 50.0)).collect();
let dense_path = PathData::single(SubPath::polyline(&dense, false));
let dense_bez = dense_path.to_bezpath();
let compact = stroke_bez(&dense_bez, width, cap, join, miter);
let raw = stroke_bez_without_spine_compact(&dense_bez, width, cap, join, miter);
assert!(outline_max_deviation(&compact, &raw) <= 1e-3, "colinear compaction must not refit the stroke outline");
let sparse =
PathData::single(SubPath::polyline(&[vectorcraft_geom::Point::new(0.0, 50.0), vectorcraft_geom::Point::new(100.0, 50.0)], false));
let sparse_outline = outline_stroke(&sparse, width, cap, join, miter);
let compact_region = stroke_region(&[compact]);
assert!(
outline_max_deviation(&compact_region.to_bezpath(), &sparse_outline.to_bezpath()) <= 1e-3,
"compacted colinear spine should outline like the endpoints alone"
);
// Sampled arc: only redundant vertices are removed; outline must track the raw stroke.
let arc: Vec<vectorcraft_geom::Point> = (0..=32)
.map(|i| {
let t = i as f64 / 32.0 * std::f64::consts::PI;
vectorcraft_geom::Point::new(50.0 + 50.0 * t.cos(), 50.0 + 8.0 * t.sin())
})
.collect();
let arc_bez = PathData::single(SubPath::polyline(&arc, false)).to_bezpath();
let arc_compact = stroke_bez(&arc_bez, width, cap, join, miter);
let arc_raw = stroke_bez_without_spine_compact(&arc_bez, width, cap, join, miter);
assert!(outline_max_deviation(&arc_compact, &arc_raw) <= 1e-3, "curved spine compaction must not refit the stroke outline");
}
}
+6
View File
@@ -351,6 +351,12 @@ impl FaceMerger {
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines, snap: std::cell::OnceCell::new() }
}
/// Merge faces found with Gap Detection using the same temporary bridges as Live Paint.
/// Source indices stay those of the original inputs; bridges have no fill and are never art.
pub fn with_gap(shapes: &[Shape], gap: f64) -> Self {
Self::new(&crate::planar::gap_inputs(shapes, gap))
}
/// The union of `faces`.
pub fn merge(&self, faces: &[&Region]) -> PathData {
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
+105 -1
View File
@@ -613,12 +613,44 @@ pub fn has_open_path(shapes: &[Shape]) -> bool {
/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas
/// count and there are no pieces.
pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
shape_builder_with_gap(shapes, edges, 0.0)
}
/// Most distance (document units) a Shape Builder gap may span.
pub const SHAPE_BUILDER_MAX_GAP: f64 = 72.0;
/// Original inputs and the shared detector's temporary, unfilled bridges. Keep the originals'
/// order and keys so only their split pieces can be returned as art.
pub(crate) fn gap_inputs(shapes: &[Shape], gap: f64) -> Vec<Shape> {
let mut closed = shapes.to_vec();
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
if segments <= SHAPE_BUILDER_MAX_SEGMENTS && gap.is_finite() && gap > 0.0 && has_open_path(shapes) {
let gaps = find_gaps(shapes, gap.min(SHAPE_BUILDER_MAX_GAP));
if !gaps.is_empty() {
let path = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect());
closed.push(Shape::new(path, FillRule::NonZero, u64::MAX));
}
}
closed
}
/// [`shape_builder`] with Gap Detection: open paths whose ends come within `gap` (document units)
/// of another path are bridged for the regions they bound, so a region left open by a small gap is
/// found. A `gap` of 0 is [`shape_builder`]. The bridges are in no region's sources and in no piece
/// of `lines` or `edges`.
pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> BuilderArrangement {
let has_open = has_open_path(shapes);
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS)
.then(|| {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
live_paint(&keyed)
if has_open && gap.is_finite() && gap > 0.0 {
let (faces, pieces) = live_paint(&gap_inputs(&keyed, gap));
// Gap targets split original outlines too, but the bridges themselves aren't art.
(faces, pieces.into_iter().filter(|p| p.key < shapes.len() as u64).collect())
} else {
live_paint(&keyed)
}
})
.filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty());
let Some((faces, pieces)) = planar else {
@@ -1542,6 +1574,78 @@ mod tests {
assert_eq!(lines.len(), 9);
}
#[test]
fn shape_builder_merges_adjacent_gap_closed_faces_without_keeping_bridges() {
let outline = Shape::new(
PathData::single(SubPath::polyline(
&[Point::new(0.0, 4.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0), Point::new(0.0, 0.0)],
false,
)),
FillRule::NonZero,
0,
);
let shapes = [outline, line((50.0, 0.0), (50.0, 100.0), 1)];
let arr = shape_builder_with_gap(&shapes, true, 6.0);
assert_eq!(arr.regions.len(), 2);
let faces: Vec<_> = arr.regions.iter().collect();
let merged = crate::FaceMerger::with_gap(&shapes, 6.0).merge(&faces);
assert!((crate::area(&merged, FillRule::NonZero).abs() - 10000.0).abs() < 1e-6);
assert_eq!(merged.subpaths.len(), 1, "the shared edge disappears");
assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key < 2), "bridges are not art");
// A request over 72 pt cannot close a gap of 80 pt.
let large = [Shape::new(
PathData::single(SubPath::polyline(
&[Point::new(0.0, 80.0), Point::new(0.0, 200.0), Point::new(200.0, 200.0), Point::new(200.0, 0.0), Point::new(0.0, 0.0)],
false,
)),
FillRule::NonZero,
0,
)];
assert!(shape_builder_with_gap(&large, false, 1000.0).regions.is_empty());
}
#[test]
fn shape_builder_gap_detection_closes_small_gaps() {
// Three sides of a 100 x 100 square, the fourth open by 4: no region without Gap Detection.
let open = |gap: f64| {
let pts = [(0.0, gap), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)];
let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect();
vec![Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0)]
};
let s = open(4.0);
assert!(shape_builder(&s, false).regions.is_empty());
assert!(shape_builder_with_gap(&s, false, 3.0).regions.is_empty(), "a gap longer than the length stays open");
let arr = shape_builder_with_gap(&s, true, 6.0);
assert_eq!(arr.regions.len(), 1);
assert!((area(&arr.regions[0]) - 10000.0).abs() < 1e-6);
// The bridge is no art: the path comes back as it was, in its own pieces only.
assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key == 0));
let len: f64 = arr.lines.iter().map(|p| p.path.to_bezpath().perimeter(1e-6)).sum();
assert!((len - 396.0).abs() < 1e-6, "{len}");
// Two paths whose ends miss each other: a U and a lid 3 above it.
let u = Shape::new(
PathData::single(SubPath::polyline(
&[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)],
false,
)),
FillRule::NonZero,
0,
);
let s = [u, line((0.0, -3.0), (100.0, -3.0), 1)];
assert!(shape_builder_with_gap(&s, false, 2.0).regions.is_empty());
let arr = shape_builder_with_gap(&s, false, 3.5);
assert_eq!(arr.regions.len(), 1);
assert!((area(&arr.regions[0]) - 10300.0).abs() < 1e-6);
// An end close to the side of another path bridges to the nearest point on it.
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((50.0, 140.0), (50.0, 104.0), 1)];
assert_eq!(shape_builder_with_gap(&s, false, 5.0).regions.len(), 1, "nothing is enclosed, nothing breaks");
// Bad gaps are no gap.
let s = open(4.0);
for gap in [f64::NAN, f64::INFINITY, -3.0] {
assert!(shape_builder_with_gap(&s, false, gap).regions.is_empty());
}
}
#[test]
fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() {
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
+14
View File
@@ -294,6 +294,20 @@ fn outline_stroke_of_line() {
assert!(close(ar(&o), 216.0, 1e-6));
}
#[test]
fn outline_stroke_of_straight_line_with_round_caps_is_compact() {
let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false));
let o = outline_stroke(&l, 10.0, Cap::Round, Join::Round, 4.0);
assert!(close(ar(&o), 100.0 * 10.0 + PI * 5.0 * 5.0, 1e-3), "{}", ar(&o));
let anchors = o.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert!(anchors <= 8, "round-cap expand should stay compact, got {anchors} anchors");
let dense: Vec<Point> = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect();
let dense_path = PathData::single(SubPath::polyline(&dense, false));
let dense_o = outline_stroke(&dense_path, 10.0, Cap::Round, Join::Round, 4.0);
let dense_anchors = dense_o.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
}
#[test]
fn outline_stroke_of_square_is_ring() {
let o = outline_stroke(&rect(0.0, 0.0, 10.0, 10.0), 2.0, Cap::Butt, Join::Miter, 10.0);
+120
View File
@@ -0,0 +1,120 @@
//! What draws differently after an edit, so a view can draw again only there.
use std::sync::Arc;
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
use vectorcraft_geom::{Affine, Rect};
/// What may draw differently in a document than in its version before an edit ([`changed`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Changed {
Nothing,
/// This area of the document, outside of which every pixel draws as before.
Area(Rect),
Everything,
}
/// What may draw differently in `new` than in `old`, the version before an edit: the bounds of
/// everything the objects that changed paint, before and after the edit ([`crate::painted_bounds`]),
/// or everything when more than the art in the layers changed, or while a pattern is edited (its
/// art also draws in the copies round it). Objects that an edit doesn't touch
/// keep their identity, so this costs what changed, not the size of the document.
pub fn changed(old: &Document, new: &Document) -> Changed {
if new.pattern_edit.is_some() || !old.same_outside_layers(new) {
return Changed::Everything;
}
let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None };
diff.children(&old.layers, &new.layers);
diff.area.map_or(Changed::Nothing, Changed::Area)
}
/// The pixels of a `w`×`h` render through `view` that drawing `area` again covers, as x0, y0, x1,
/// y1: its box in the view, grown by a pixel for the anti-aliased edges. None when it covers none.
pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> {
let r = view.transform_rect_bbox(area).inflate(1.0, 1.0);
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|c| c.is_finite()) {
return None;
}
let (x0, y0) = (r.x0.floor().max(0.0), r.y0.floor().max(0.0));
let (x1, y1) = (r.x1.ceil().min(f64::from(w)), r.y1.ceil().min(f64::from(h)));
(x1 > x0 && y1 > y0).then_some([x0 as u32, y0 as u32, x1 as u32, y1 as u32])
}
/// [`changed`] in progress: the symbols' boxes of both versions, and the area so far.
struct Diff<'a> {
old: SymbolBoxes<'a>,
new: SymbolBoxes<'a>,
area: Option<Rect>,
}
impl Diff<'_> {
/// Members in the same order compare one by one. Members added, removed or restacked count
/// before and after, except those that kept their place unchanged.
fn children(&mut self, old: &[Arc<Node>], new: &[Arc<Node>]) {
if old.len() == new.len() && old.iter().zip(new).all(|(a, b)| a.id == b.id) {
for (a, b) in old.iter().zip(new) {
self.node(a, b);
}
return;
}
for i in 0..old.len().max(new.len()) {
match (old.get(i), new.get(i)) {
(Some(a), Some(b)) if Arc::ptr_eq(a, b) => {}
(a, b) => {
self.add_old(a.map(|a| &**a));
self.add_new(b.map(|b| &**b));
}
}
}
}
/// A group or layer that only its members changed in, without a clipping mask or effects of
/// its own that draw from all of them, changed where they did. Any other object that changed
/// counts whole, before and after.
fn node(&mut self, old: &Arc<Node>, new: &Arc<Node>) {
if Arc::ptr_eq(old, new) {
return;
}
if let (Some(a), Some(b)) = (members(old), members(new))
&& same_but_members(old, new)
{
self.children(a, b);
} else {
self.add_old(Some(old));
self.add_new(Some(new));
}
}
fn add_old(&mut self, n: Option<&Node>) {
self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.old, n)));
}
fn add_new(&mut self, n: Option<&Node>) {
self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.new, n)));
}
fn add(&mut self, r: Option<Rect>) {
self.area = vectorcraft_geom::union_opt(self.area, r);
}
}
/// The members of a group or layer whose members draw each on their own: no clipping mask, and no
/// effects of its own.
fn members(n: &Node) -> Option<&[Arc<Node>]> {
match &n.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !crate::fx::has_object_fx(n) => Some(children),
_ => None,
}
}
/// Are `a` and `b` the same but for their members?
fn same_but_members(a: &Node, b: &Node) -> bool {
let strip = |n: &Node| {
let mut n = n.clone();
if let Some(children) = n.children_mut() {
children.clear();
}
n
};
strip(a) == strip(b)
}
+45 -12
View File
@@ -478,12 +478,25 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
}
_ => {
let b = n.visual_bounds_with(symbols)?;
let b = match &n.kind {
NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b),
_ => b,
};
let a = live_content_reach(symbols, n);
Some(b.inflate(a, a))
}
}
}
/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its
/// characters' strokes and by `outset`: on a path they stand above it and run past its ends, in an
/// area they can hang past its frame, where its own box ([`Node::visual_bounds_with`]) doesn't.
fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option<Rect> {
let b = t.cached_bounds.unwrap_or_else(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds);
let o = outset.max(t.char_stroke_outset());
(b.width() > 0.0 || b.height() > 0.0).then(|| t.xf.transform_rect_bbox(b).inflate(o, o))
}
/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual
/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that
/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the
@@ -643,7 +656,7 @@ pub(crate) struct PixelEntry {
/// For one cropped to the view, or made at a lower resolution, the view and the rect it was
/// made for: it draws only there, so dragging other art over the object keeps it (a wide blur,
/// or any blur zoomed in, reaches past the pixel budget).
view: Option<ViewKey>,
view: Option<ViewCrop>,
made: Made,
/// What the content read when the raster was made, with the channels the effects ran on and,
/// under De-Interlace, the raster resolution.
@@ -672,13 +685,29 @@ fn linear_key(view: Affine) -> [u64; 4] {
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
}
/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a
/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws
/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]).
type ViewKey = ([u64; 6], [u64; 4]);
/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and
/// the device-pixel rect of the frame it was made in ([`PixelEntry::view`], [`ShadowEntry::view`]).
#[derive(Clone, Copy, Debug)]
struct ViewCrop {
pan: Vec2,
screen: Rect,
}
fn view_key(view: Affine, screen: Rect) -> ViewKey {
(view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits))
impl ViewCrop {
fn of(view: Affine, screen: Rect) -> Self {
Self { pan: view.translation(), screen }
}
/// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto
/// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies
/// within the one the raster was made in: the same pixels in the same places, as when a frame
/// is drawn again only in a part of it.
fn serves(&self, view: Affine, screen: Rect) -> bool {
let d = view.translation() - self.pan;
let whole = Vec2::new(d.x.round(), d.y.round());
let s = screen - whole;
(d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1
}
}
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
@@ -768,7 +797,7 @@ pub(crate) struct ShadowEntry {
/// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one
/// cropped to the view, or made over such a raster, the view and the rect it was made for: it
/// draws only there ([`PixelEntry::view`]).
view: Option<ViewKey>,
view: Option<ViewCrop>,
made: Made,
/// What the silhouette read when the raster was made.
inputs: Inputs,
@@ -1084,9 +1113,9 @@ impl Renderer {
// What the silhouette reads: as the raster kept for `n` found it, else found now.
let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads);
let inputs = self.raster_inputs(f, n, reads);
let in_view = view_key(f.view, screen);
let in_view = ViewCrop::of(f.view, screen);
let exact = |e: &ShadowEntry| {
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view)
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen))
};
if interacting && !self.shadows.get(&key).is_some_and(exact) {
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
@@ -1784,7 +1813,7 @@ impl Renderer {
// The cached raster: of the whole content at full size at this zoom, else of this view.
let linear = linear_key(f.view);
let whole = fits && k >= 1.0;
let in_view = (!whole).then(|| view_key(f.view, screen));
let in_view = (!whole).then(|| ViewCrop::of(f.view, screen));
let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink));
let stamp = self.stamp;
let channels = match f.ink {
@@ -1803,8 +1832,12 @@ impl Renderer {
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
});
let interacting = f.opts.interacting;
let in_place = |e: &PixelEntry| match e.view {
None => whole,
Some(v) => !whole && v.serves(f.view, screen),
};
let exact = |e: &PixelEntry| {
e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved)
e.linear == linear && in_place(e) && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved)
};
if interacting
&& whole
+17 -14
View File
@@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
mod brush_fx;
mod changed;
pub mod encode;
mod freeform;
mod fx;
@@ -35,6 +36,7 @@ use ink::Ink;
use lru::FrameCache;
pub use brush_fx::instance_art;
pub use changed::{Changed, changed, view_pixels};
pub use effects::stroke::width_outline;
pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget};
pub use live::expand_live;
@@ -101,7 +103,7 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> {
}
/// Rendering options.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct RenderOptions {
/// Outline (wireframe) view: 1 px black paths, no paint.
pub outline: bool,
@@ -444,8 +446,6 @@ pub struct Renderer {
text_fonts: u64,
/// Clip regions per clipping path (see [`Self::clip_of`]).
clips: FrameCache<usize, ClipEntry>,
/// Context reused when rendering single-threaded (`threads == 0`).
ctx_st: Option<RenderContext>,
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
pub threads: u16,
geom: PtrMap<usize, GeomEntry>,
@@ -476,7 +476,10 @@ pub struct Renderer {
stamp: u64,
/// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer).
alpha: f32,
ctx: Option<RenderContext>,
/// The contexts kept for the next frames, with the threads they draw on, the last used last: a
/// frame of the size of one reuses it instead of making a context and its threads. Two are
/// kept, so a view's frames and the parts of them drawn again ([`changed`]) each keep theirs.
contexts: Vec<(u16, RenderContext)>,
resources: Resources,
/// Statistics of the last frame.
pub stats: FrameStats,
@@ -613,7 +616,6 @@ impl Renderer {
texts: FrameCache::new(lru::TEXT_BUDGET, 1024),
text_fonts: 0,
clips: FrameCache::new(lru::CLIP_BUDGET, 256),
ctx_st: None,
threads: default_threads(),
geom: PtrMap::default(),
symbols: vec![],
@@ -625,7 +627,7 @@ impl Renderer {
fx_geom: PtrMap::default(),
stamp: 0,
alpha: 1.0,
ctx: None,
contexts: Vec::new(),
resources: Resources::new(),
stats: FrameStats::default(),
brushes: Default::default(),
@@ -761,13 +763,13 @@ impl Renderer {
/// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art.
fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec<u8> {
let (threads, opts) = (self.threads, f.opts);
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
let mut ctx = match slot {
Some(mut c) if c.width() == w && c.height() == h => {
let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h);
let mut ctx = match kept.map(|i| self.contexts.remove(i)) {
Some((_, mut c)) => {
c.reset();
c
}
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
None => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
};
// `reset` keeps the threshold of the previous render: set it every time.
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
@@ -800,10 +802,9 @@ impl Renderer {
self.draw_page(&mut ctx, f);
}
let pm = self.finish(&mut ctx, self.raster);
if threads == 0 {
self.ctx_st = Some(ctx);
} else {
self.ctx = Some(ctx);
self.contexts.push((threads, ctx));
if self.contexts.len() > 2 {
self.contexts.remove(0);
}
pm.data_as_u8_slice().to_vec()
}
@@ -2560,6 +2561,8 @@ mod tests_alternate;
#[cfg(test)]
mod tests_blend;
#[cfg(test)]
mod tests_changed;
#[cfg(test)]
mod tests_charstroke;
#[cfg(test)]
mod tests_clip;
+215
View File
@@ -0,0 +1,215 @@
//! Drawing again only what an edit changed gives the pixels of drawing it all again.
use super::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::Appearance;
use vectorcraft_geom::{Point, shapes};
const W: u32 = 160;
const H: u32 = 120;
/// A view that doesn't line the art up with the pixels, so the edges are anti-aliased.
fn view() -> Affine {
Affine::translate((3.3, 2.7)) * Affine::scale(1.37)
}
fn rect(d: &mut Document, r: Rect, fill: Color) -> Node {
let id = d.alloc_id();
Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::solid(Color::BLACK), 2.0))
}
fn shadow() -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0}),
visible: true,
}
}
/// A layer with a rectangle, a group at half opacity holding two, a clip group, and one with a
/// drop shadow; the ids of the loose rectangle, a member of each group, and the shadowed one.
fn document() -> (Document, [NodeId; 5]) {
let mut d = Document::new(110.0, 80.0);
let l = d.layers[0].id;
let loose = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 20.0), Color::rgb(1.0, 0.0, 0.0));
let loose = d.insert(Some(l), 0, loose).unwrap();
let (a, b) = (
rect(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Color::rgb(0.0, 0.6, 0.0)),
rect(&mut d, Rect::new(40.0, 15.0, 60.0, 35.0), Color::rgb(0.0, 0.0, 1.0)),
);
let mut group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(a), Arc::new(b)], clip: false });
group.opacity = 0.5;
let member = group.children().unwrap()[1].id;
d.insert(Some(l), 1, group).unwrap();
let (mask, art) =
(rect(&mut d, Rect::new(5.0, 40.0, 30.0, 60.0), Color::WHITE), rect(&mut d, Rect::new(0.0, 45.0, 40.0, 55.0), Color::rgb(0.8, 0.4, 0.0)));
let clipped = art.id;
let clip = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(art)], clip: true });
d.insert(Some(l), 2, clip).unwrap();
let mut shadowed = rect(&mut d, Rect::new(60.0, 45.0, 80.0, 60.0), Color::rgb(0.9, 0.9, 0.2));
shadowed.appearance.effects.push(shadow());
let shadowed = d.insert(Some(l), 3, shadowed).unwrap();
let other = rect(&mut d, Rect::new(85.0, 10.0, 105.0, 30.0), Color::rgb(0.5, 0.0, 0.5));
let other = d.insert(Some(l), 4, other).unwrap();
(d, [loose, member, clipped, shadowed, other])
}
/// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that
/// this is `new` drawn whole; returns what changed.
fn redraw(old: &Document, new: &Document) -> Changed {
let mut r = Renderer::new();
let opts = RenderOptions::default();
let before = r.render(old, W, H, view(), &opts);
let after = r.render(new, W, H, view(), &opts);
let change = changed(old, new);
let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() };
match change {
Changed::Everything => patched.pixels = after.pixels.clone(),
Changed::Nothing => {}
Changed::Area(area) => {
if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) {
let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts);
for y in 0..y1 - y0 {
let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize);
let n = ((x1 - x0) * 4) as usize;
patched.pixels[to..to + n].copy_from_slice(&part.pixels[from..from + n]);
}
}
}
}
assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all");
change
}
fn area(c: Changed) -> Rect {
match c {
Changed::Area(a) => a,
other => panic!("an area: {other:?}"),
}
}
#[test]
fn moving_an_object_changes_where_it_was_and_is() {
let (old, [loose, ..]) = document();
let mut new = old.clone();
new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false);
let a = area(redraw(&old, &new));
assert!(a.contains(Point::new(6.0, 6.0)) && a.contains(Point::new(31.0, 22.0)), "before and after: {a:?}");
assert!(a.x1 < 35.0, "not the objects beside it: {a:?}");
}
#[test]
fn recolouring_a_member_of_a_translucent_group_changes_only_the_member() {
let (old, [_, member, ..]) = document();
let mut new = old.clone();
new.node_mut(member).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(1.0, 0.5, 0.0));
let a = area(redraw(&old, &new));
assert!(a.x0 > 35.0, "not the group's other member: {a:?}");
}
#[test]
fn moving_clipped_art_changes_its_whole_clip_group() {
let (old, [_, _, clipped, ..]) = document();
let mut new = old.clone();
new.node_mut(clipped).unwrap().transform(Affine::translate((0.0, 4.0)), false);
redraw(&old, &new);
}
#[test]
fn moving_a_shadowed_object_changes_where_its_shadow_falls() {
let (old, [.., shadowed, _]) = document();
let mut new = old.clone();
new.node_mut(shadowed).unwrap().transform(Affine::translate((-5.0, 5.0)), false);
let a = area(redraw(&old, &new));
assert!(a.x1 > 86.0 && a.y1 > 70.0, "the shadow, before and after: {a:?}");
}
#[test]
fn adding_removing_and_restacking_change_those_objects() {
let (old, [loose, _, _, _, other]) = document();
let mut added = old.clone();
let n = rect(&mut added, Rect::new(70.0, 5.0, 90.0, 15.0), Color::BLACK);
let l = added.layers[0].id;
added.insert(Some(l), 5, n).unwrap();
redraw(&old, &added);
let mut removed = old.clone();
removed.remove(loose).unwrap();
redraw(&old, &removed);
let mut restacked = old.clone();
let n = restacked.remove(other).unwrap();
restacked.insert(Some(l), 0, (*n).clone()).unwrap();
redraw(&old, &restacked);
}
/// A blur too wide for a raster of all of it is cropped to the frame. A part of the frame drawn
/// again, during a drag too, draws it from that raster: the same pixels, and no preview at the
/// drag's lower resolution.
#[test]
fn a_part_of_a_frame_draws_a_cropped_blur_from_the_frames_raster() {
let (mut d, _) = document();
let mut back = rect(&mut d, Rect::new(0.0, 0.0, 110.0, 80.0), Color::rgb(0.1, 0.3, 0.9));
back.appearance.effects.push(vectorcraft_doc::Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 500.0}), visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, back).unwrap();
let view = Affine::scale(2.0) * Affine::translate((-20.0, -15.0));
let mut r = Renderer::new();
let whole = r.render(&d, W, H, view, &RenderOptions::default());
let [x0, y0, x1, y1] = [40, 30, 100, 80];
let dragging = RenderOptions { interacting: true, dragging: true, ..Default::default() };
let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &dragging);
assert_eq!(r.stats.previewed, 0, "drawn from the frame's raster");
for y in 0..y1 - y0 {
for x in 0..x1 - x0 {
assert_eq!(part.pixel(x, y), whole.pixel(x0 + x, y0 + y), "at {x}, {y}");
}
}
}
/// Type on a path whose path a script reshapes: its letters stand above the path and run past
/// where it ends, and they change where they were and where they are, outside the path's box.
#[test]
fn reshaping_the_path_of_type_on_a_path_changes_where_its_letters_were_and_are() {
let mut old = Document::new(110.0, 80.0);
let l = old.layers[0].id;
let on = |y: f64| {
let mut t = vectorcraft_doc::text::TextObject::point(
Point::ZERO,
"Text along a path",
vectorcraft_doc::text::CharStyle { size: 14.0, ..Default::default() },
);
let line = kurbo::Line::new((10.0, 60.0), (90.0, y)).to_path(0.1);
t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
// The engine lays out type that changed ([`vectorcraft_doc::TextObject::cached_bounds`]).
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
t
};
let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(on(55.0))))).unwrap();
let mut new = old.clone();
if let Some(NodeKind::Text(t)) = new.node_mut(text).map(|n| &mut n.kind) {
**t = on(25.0);
}
redraw(&old, &new);
}
#[test]
fn an_unchanged_document_changes_nothing_and_a_new_symbol_everything() {
let (old, _) = document();
assert_eq!(redraw(&old, &old.clone()), Changed::Nothing);
let mut new = old.clone();
let art = Arc::new(rect(&mut new, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK));
new.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art });
assert_eq!(redraw(&old, &new), Changed::Everything);
}
/// While a pattern is edited its art also draws in the copies round it: an edit of it changes
/// everything.
#[test]
fn an_edit_while_a_pattern_is_edited_changes_everything() {
let (mut old, [loose, ..]) = document();
let l = old.layers[0].id;
old.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: l, original: None });
let mut new = old.clone();
new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false);
assert_eq!(changed(&old, &new), Changed::Everything);
}
+18 -4
View File
@@ -1897,9 +1897,11 @@ impl Writer<'_> {
fn text(&mut self, n: &Node, t: &TextObject) {
// Live SVG text is written in logical order from left-aligned pieces: bidirectional text
// (and right-to-left paragraphs) keep their look as outlines until it is written with
// `direction`/`unicode-bidi`.
// `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose
// `direction="rtl"` would anchor their pieces at the right end.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) {
let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft));
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs {
self.warn("bidirectional text is outlined to preserve shaping and visual order");
return self.text_outlines(n, t);
}
@@ -2047,7 +2049,8 @@ impl Writer<'_> {
if st.rotation != 0.0 {
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
}
let diff = run_diff(&base.props, self.char_props(st, base.space));
let mut diff = run_diff(&base.props, self.char_props(st, base.space));
diff.extend(direction_props(st));
attrs.push_str(&self.attrs(&diff));
let text = xml_text(&seg.text);
if attrs.is_empty() && fewer {
@@ -2177,7 +2180,8 @@ impl Writer<'_> {
let offset = fmt_num(start * 100.0, 3);
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
for run in &t.runs {
let diff = run_diff(&base, self.char_props(&run.style, space));
let mut diff = run_diff(&base, self.char_props(&run.style, space));
diff.extend(direction_props(&run.style));
let a = self.attrs(&diff);
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_text(&run.text.replace('\n', " "))));
}
@@ -2210,6 +2214,16 @@ fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool {
lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin)
}
/// The `direction` and `unicode-bidi` of a `<tspan>` of characters in style `st`: an embedding in
/// its Character Direction, and nothing without one. Each `<tspan>` of characters with a direction
/// carries them, and the `<text>` does not. Type with a run set Right to Left is written as
/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only.
fn direction_props(st: &CharStyle) -> Props {
let Some(d) = st.char_direction else { return Props::new() };
let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" };
vec![("direction", direction.into()), ("unicode-bidi", "embed".into())]
}
/// What the `<tspan>` of a run whose properties are `run` sets over its `<text>`'s `base`: the
/// properties that differ, and the initial value of each one `base` sets and the run doesn't.
fn run_diff(base: &Props, run: Props) -> Props {
+37
View File
@@ -125,6 +125,43 @@ fn proportional_metrics_write_palt() {
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
}
/// Character Direction: live text writes each run set Left to Right as a bidi embedding
/// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines.
#[test]
fn character_direction_is_written_on_its_runs() {
use vectorcraft_doc::ParaDirection;
let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) };
let d = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr.clone()), run(" ok", style(12.0))], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert_eq!(tags.len(), 3, "{svg}");
assert!(!tags[0].contains("direction") && !tags[2].contains("direction"), "{svg}");
assert!(tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{}", tags[1]);
// Type on a path: on the run's `<tspan>` in the `<textPath>` too.
let mut on_path = TextObject::point(Point::ZERO, "", style(12.0));
on_path.runs = vec![run("Due ", style(12.0)), run("21 10 2026", ltr)];
let mut line = BezPath::new();
line.move_to((20.0, 100.0));
line.line_to((280.0, 100.0));
on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(on_path)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert!(svg.contains("<textPath") && tags.len() == 2, "{svg}");
assert!(!tags[0].contains("direction") && tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{svg}");
// A run set Right to Left is written as outlines, also when its characters are drawn left to
// right (digits inside an embedding stay left to right).
let rtl = CharStyle { char_direction: Some(ParaDirection::RightToLeft), ..style(12.0) };
for date in ["21 10 2026", "2026"] {
let d = area_doc(vec![run("Due ", style(12.0)), run(date, rtl.clone())], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("<text"), "{date}: {svg}");
}
}
/// Digits are written as drawn: the style's numeral system, not the digits that were typed (as
/// All Caps writes capitals).
#[test]
+65 -2
View File
@@ -50,6 +50,23 @@ impl Ctx<'_> {
find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default)
}
/// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges
/// from `r.start` with their direction, in order; neighboring runs of one direction make one
/// stretch.
fn char_directions(&self, r: &Range<usize>) -> Vec<(Range<usize>, ParaDirection)> {
let mut out: Vec<(Range<usize>, ParaDirection)> = Vec::new();
for (rr, st) in &self.runs {
let (a, b) = (rr.start.max(r.start), rr.end.min(r.end));
let Some(d) = st.char_direction.filter(|_| a < b) else { continue };
let (a, b) = (a - r.start, b - r.start);
match out.last_mut() {
Some((prev, pd)) if *pd == d && prev.end == a => prev.end = b,
_ => out.push((a..b, d)),
}
}
out
}
/// Shape paragraph `r` (its bidi resolution `bidi`).
fn shape_para(&self, r: Range<usize>, bidi: Option<&BidiInfo<'_>>) -> Vec<SGlyph> {
let mut v = Vec::with_capacity(r.len());
@@ -1908,7 +1925,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
// its first line) follow its own.
pen.model = para.leading_model;
let text = cx.text;
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr));
let rtl = is_rtl(bidi.as_ref());
let mut sg = cx.shape_para(pr.clone(), bidi.as_ref());
// Thai, Lao, Khmer and Myanmar break lines between words, not at spaces.
@@ -2310,7 +2327,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
let mut shaped = Vec::new();
for pr in paras {
let text = cx.text;
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr));
rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical);
let glyphs = cx.shape_para(pr.clone(), bidi.as_ref());
shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs));
@@ -2554,6 +2571,52 @@ pub(crate) fn para_bidi(text: &str, direction: Option<ParaDirection>) -> Option<
Some(BidiInfo::new(text, level))
}
/// [`para_bidi`] for a paragraph whose characters in `set` (byte ranges of `text`, in order and not
/// overlapping, from [`Ctx::char_directions`]) have a Character Direction: each range is resolved
/// as a Unicode bidi embedding, as if an LRE or RLE stood before it and a PDF after it (UAX #9 X2,
/// X3, X7). The levels, classes and paragraphs come back for the bytes of `text`. The paragraph's own
/// direction is unchanged: the first strong character is found inside embeddings too (P2).
pub(crate) fn para_bidi_with<'a>(text: &'a str, direction: Option<ParaDirection>, set: &[(Range<usize>, ParaDirection)]) -> Option<BidiInfo<'a>> {
let plain = para_bidi(text, direction);
if set.is_empty() || (plain.is_none() && set.iter().all(|(_, d)| *d == ParaDirection::LeftToRight)) {
// A left-to-right embedding in left-to-right text takes an even level: its order is the same.
return plain;
}
// The text with the embedding controls, and where each byte of `text` is in it.
let mut marked = String::with_capacity(text.len() + set.len() * 6);
let mut at_byte: Vec<usize> = Vec::with_capacity(text.len());
let mut copy = |marked: &mut String, from: usize, to: usize| {
if let Some(s) = text.get(from..to) {
at_byte.extend(marked.len()..marked.len() + s.len());
marked.push_str(s);
}
};
let mut done = 0;
for (r, d) in set {
let (a, b) = (r.start.max(done).min(text.len()), r.end.min(text.len()));
if a >= b {
continue;
}
copy(&mut marked, done, a);
marked.push(if *d == ParaDirection::RightToLeft { '\u{202B}' } else { '\u{202A}' });
copy(&mut marked, a, b);
marked.push('\u{202C}');
done = b;
}
copy(&mut marked, done, text.len());
let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() });
let info = BidiInfo::new(&marked, level);
let back = |i: usize| at_byte.partition_point(|&m| m < i);
let paragraphs =
info.paragraphs.iter().map(|p| unicode_bidi::ParagraphInfo { range: back(p.range.start)..back(p.range.end), level: p.level }).collect();
Some(BidiInfo {
text,
original_classes: at_byte.iter().filter_map(|&i| info.original_classes.get(i).copied()).collect(),
levels: at_byte.iter().filter_map(|&i| info.levels.get(i).copied()).collect(),
paragraphs,
})
}
/// Does the paragraph run right to left ([`para_bidi`])?
pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool {
bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl())
+5 -3
View File
@@ -433,9 +433,10 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
/// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text?
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
/// character style's name, leading, baseline shift, rotation, Character Alignment, the kerning
/// (each glyph takes its run's, see [`shape_segment`]) and the tracking, as long as the tracking
/// keeps or drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
/// Direction (text is shaped apart where its bidi level changes), the kerning (each glyph takes its
/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard
/// ligatures alike ([`crate::features::ligatures_suppressed_by`]).
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
// Every field is named, so that a new attribute has to be sorted into one group or the other.
let CharStyle {
@@ -469,6 +470,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
position,
small_caps,
char_align: _,
char_direction: _,
proportional_metrics,
tate_chu_yoko,
digits,
+36 -1
View File
@@ -1,8 +1,9 @@
//! Direction, source-cluster and editing regressions; no RTL font assets required.
use super::*;
use kurbo::{Affine, BezPath, Point, Rect, Shape};
use std::ops::Range;
use std::sync::Arc;
use vectorcraft_doc::{CharStyle, TextKind, TextRun};
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextRun};
fn text_layout(s: &str) -> TextLayout {
layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default()))
@@ -88,6 +89,40 @@ fn mixed_text_preserves_latin_and_number_order() {
assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text");
}
/// The characters of `s` in the order a layout of it draws them, left to right, with the bytes
/// `set` given Character Direction `d`.
fn drawn_with_direction(s: &str, set: Range<usize>, d: Option<ParaDirection>) -> String {
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
edit::style_range(&mut t.runs, set.start, set.end, |st| st.char_direction = d);
let l = layout(FontDb::global(), &t);
let mut glyphs: Vec<_> = l.glyphs.iter().collect();
glyphs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect()
}
/// Character Direction Left to Right keeps a date written with spaces in its own order inside a
/// right-to-left paragraph, where the bidi algorithm alone sets its numbers right to left.
#[test]
fn left_to_right_characters_keep_a_date_in_order_in_right_to_left_text() {
let s = "שלום 21 10 2026";
let date = s.find('2').unwrap_or_default()..s.len();
assert_eq!(drawn_with_direction(s, date.clone(), None), "2026 10 21 םולש");
assert_eq!(drawn_with_direction(s, date, Some(ParaDirection::LeftToRight)), "21 10 2026 םולש");
}
/// Character Direction Right to Left sets a Hebrew word and the exclamation mark after it right to
/// left inside a left-to-right paragraph, where the bidi algorithm alone leaves the mark after the
/// word in the paragraph's direction.
#[test]
fn right_to_left_characters_set_a_word_and_its_mark_right_to_left_in_left_to_right_text() {
let s = "abc שלום! def";
let word = s.find('ש').unwrap_or_default()..s.find('!').map_or(0, |i| i + 1);
assert_eq!(drawn_with_direction(s, word.clone(), None), "abc םולש! def");
assert_eq!(drawn_with_direction(s, word.clone(), Some(ParaDirection::RightToLeft)), "abc !םולש def");
// Left to Right on the same characters of a left-to-right paragraph draws them as without it.
assert_eq!(drawn_with_direction(s, word, Some(ParaDirection::LeftToRight)), "abc םולש! def");
}
#[test]
fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() {
let s = "שלום";
+10 -4
View File
@@ -142,12 +142,18 @@ fn vertical_ruby_is_upright_to_the_right_of_the_column() {
let ruby = ruby_ink(&lay);
assert!(ruby.x0 > base.x0, "ruby right of the column: {ruby:?} vs {base:?}");
assert!(ruby.x0 >= base.x1 - 1.0, "ruby outside the column: {ruby:?} vs {base:?}");
// Upright kana are taller than wide (they would be lying on their side if not).
// Upright: each reading glyph's ink has the width and height of its outline at 10 pt (half the
// base size); a glyph on its side has them swapped.
let mut not_square = false;
for g in &lay.ruby {
if let Some(b) = ink(g) {
assert!(b.width() <= b.height() + 1.0, "upright ruby glyph {b:?}");
}
let Some(r) = ink(g) else { continue };
let face = db().face_by_id(g.font_id).expect("the ruby glyph's face");
let o = db().outline(&face, g.gid).bounding_box();
let (w, h) = (o.width() * 10.0 / face.units_per_em(), o.height() * 10.0 / face.units_per_em());
assert!((r.width() - w).abs() < 0.01 && (r.height() - h).abs() < 0.01, "upright ruby glyph {r:?}, its outline {w} x {h}");
not_square |= (w - h).abs() > 0.5;
}
assert!(not_square, "one reading glyph's outline is not square");
// Down the column: the reading's glyphs have distinct y positions.
let ys: Vec<f64> = lay.ruby.iter().filter_map(|g| ink(g).map(|b| b.center().y)).collect();
assert!(ys.windows(2).any(|w| w[0] != w[1]), "the reading runs down the column: {ys:?}");
+103 -13
View File
@@ -72,7 +72,17 @@ pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool {
/// The face of `group` under `p` (front-most).
pub fn face_at(group: &Node, p: Point) -> Option<&Arc<Node>> {
faces(group).into_iter().rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p)))
front_face(faces(group).into_iter(), p)
}
/// The face of `group` under `p` (front-most) among its faces that are not hidden.
pub fn visible_face_at(group: &Node, p: Point) -> Option<&Arc<Node>> {
front_face(faces(group).into_iter().filter(|f| f.visible), p)
}
/// The face of `faces` (paint order) under `p` (front-most).
fn front_face<'a>(faces: impl DoubleEndedIterator<Item = &'a Arc<Node>>, p: Point) -> Option<&'a Arc<Node>> {
faces.rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p)))
}
/// The edge of `group` nearest to `p` within `tol`.
@@ -99,11 +109,11 @@ fn nearest_edge<'a>(edges: impl IntoIterator<Item = &'a Arc<Node>>, p: Point, to
best.map(|b| b.1)
}
/// The visible Live Paint group with a face under `p` (front-most in paint order).
/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order).
pub fn live_paint_at(doc: &Document, p: Point) -> Option<NodeId> {
let mut hit = None;
doc.walk(|n| {
if is_live_paint(n) && doc.is_visible(n.id) && face_at(n, p).is_some() {
if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() {
hit = Some(n.id);
}
});
@@ -243,11 +253,16 @@ impl BuilderMap {
/// `open_filled_as_closed` (Consider Open Filled Paths as Closed), a filled open path bounds a
/// region as if an invisible edge closed it; without it, the path cuts like a line.
pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool) -> (Self, Vec<&'a Node>) {
Self::with_gap(doc, roots, with_edges, open_filled_as_closed, 0.0)
}
/// The same arrangement with Gap Detection, using the shared Live Paint gap finder.
pub fn with_gap<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool, gap: f64) -> (Self, Vec<&'a Node>) {
let (shapes, leaves) = shape_builder_shapes(doc, roots, open_filled_as_closed);
if shapes.is_empty() {
return (Self::default(), leaves);
}
let arr = po::shape_builder(&shapes, with_edges);
let arr = po::shape_builder_with_gap(&shapes, with_edges, gap);
let bounded = |pieces: Vec<po::Shape>| {
pieces
.into_iter()
@@ -432,6 +447,8 @@ struct RegionCache {
live: bool,
/// The map has the Shape Builder's outline edges.
edges: bool,
/// Gap Detection distance; changing tool options invalidates the cache.
gap: f64,
}
impl RegionCache {
@@ -449,7 +466,7 @@ impl RegionCache {
let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) };
BuilderMap { regions, ..Default::default() }
} else {
BuilderMap::new(cx.doc, &roots, edges, open_filled_as_closed).0
BuilderMap::with_gap(cx.doc, &roots, edges, open_filled_as_closed, self.gap).0
};
self.key = Some(key);
self.edges = edges;
@@ -463,6 +480,10 @@ impl RegionCache {
// ---------- Shape Builder ----------
pub use po::SHAPE_BUILDER_MAX_GAP;
/// Small, Medium and Large gap lengths in points.
pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0];
/// Shape Builder Tool Options (double-click the tool; `tool.setOption`), kept across tool switches
/// and saved with the preferences.
#[derive(Clone, Debug, PartialEq)]
@@ -486,11 +507,23 @@ pub struct ShapeBuilderOptions {
/// Highlight Fill (`highlightFill`, on): in merge mode, the regions under the pointer or
/// touched by the drag are shaded with a gray mesh. When it is off, they are not highlighted.
pub highlight_fill: bool,
/// Close gaps up to `gap_length` while finding regions.
pub gap_detection: bool,
/// Gap Length in points (at most [`SHAPE_BUILDER_MAX_GAP`]).
pub gap_length: f64,
}
impl Default for ShapeBuilderOptions {
fn default() -> Self {
Self { open_filled_as_closed: true, swatches: false, cursor_swatch_preview: false, straight_line: false, highlight_fill: true }
Self {
open_filled_as_closed: true,
swatches: false,
cursor_swatch_preview: false,
straight_line: false,
highlight_fill: true,
gap_detection: false,
gap_length: GAP_LENGTHS[0],
}
}
}
@@ -550,6 +583,10 @@ pub struct ShapeBuilderTool {
const LINE_TOL_PX: f64 = 4.0;
impl ShapeBuilderTool {
fn gap(&self) -> f64 {
if self.opts.gap_detection { self.opts.gap_length } else { 0.0 }
}
fn line_tol(cx: &ToolContext, alt: bool) -> Option<f64> {
alt.then(|| cx.tol(LINE_TOL_PX))
}
@@ -655,7 +692,7 @@ impl Tool for ShapeBuilderTool {
let pts: Vec<Value> = points.iter().map(|q| json!([q.x, q.y])).collect();
// The mode the drag started in, which its highlight showed.
let erase = self.alt;
let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase });
let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase, "gap": self.gap() });
if let Some(t) = Self::line_tol(cx, erase) {
params["tolerance"] = json!(t);
}
@@ -751,6 +788,8 @@ impl Tool for ShapeBuilderTool {
"cursorSwatchPreview": o.cursor_swatch_preview,
"selection": if o.straight_line { "straightLine" } else { "freeform" },
"highlightFill": o.highlight_fill,
"gapDetection": o.gap_detection,
"gapLength": o.gap_length,
})
}
@@ -762,8 +801,16 @@ impl Tool for ShapeBuilderTool {
"cursorSwatchPreview" => o.cursor_swatch_preview = v.as_bool().unwrap_or(o.cursor_swatch_preview),
"selection" => o.straight_line = v.as_str().map_or(o.straight_line, |s| s == "straightLine"),
"highlightFill" => o.highlight_fill = v.as_bool().unwrap_or(o.highlight_fill),
"gapDetection" => o.gap_detection = v.as_bool().unwrap_or(o.gap_detection),
"gapLength" => {
if let Some(n) = v.as_f64().filter(|n| n.is_finite()) {
o.gap_length = n.clamp(0.0, SHAPE_BUILDER_MAX_GAP);
}
}
_ => {}
}
self.cache.key = None;
self.cache.gap = self.gap();
}
}
@@ -780,14 +827,14 @@ enum LpHover {
Region(usize),
}
/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a face of a
/// visible Live Paint group ([`live_paint_at`]) it is that group with its face under `p`, or with
/// `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside every face,
/// with `edge`, it is the visible edge nearest `p` within `tol` among the visible Live Paint groups
/// (the front-most group's on a tie). None when no group is found.
/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a visible face
/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under
/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside
/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the
/// visible Live Paint groups (the front-most group's on a tie). None when no group is found.
fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc<Node>>)> {
if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) {
return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { face_at(g, p) }));
return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) }));
}
if !edge {
return None;
@@ -1417,6 +1464,49 @@ mod tests {
}
}
#[test]
fn live_paint_tools_skip_hidden_faces() {
let mut d = Document::new(500.0, 500.0);
let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0));
let face = |g: NodeId| faces(d.node(g).unwrap())[0].id;
let (back_face, hidden) = (face(back), face(front));
// The front group's face is hidden on its own; both groups stay visible.
d.node_mut(hidden).unwrap().visible = false;
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let part = |ov: &[Overlay]| match ov {
[] => None,
[Overlay::Path { path, .. }] => Some(path.bounding_box()),
_ => panic!("{ov:?}"),
};
// Where the faces overlap, both tools take the back group's face under the hidden one.
let mut bucket = LivePaintBucketTool::default();
bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] }));
assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]);
let mut sel = LivePaintSelectionTool::default();
sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]);
// Over the hidden face alone, neither tool highlights or picks a face.
bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0));
assert_eq!(part(&bucket.overlays(&cx)), None);
assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty());
sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0));
assert_eq!(part(&sel.overlays(&cx)), None);
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
// Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side
// as it does outside every face.
sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt));
assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0)));
let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt));
assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]);
}
#[test]
fn live_paint_selection_options_set_the_highlight() {
let (d, ..) = live_group_doc();
+5 -1
View File
@@ -42,7 +42,11 @@ const ROWS: &[Row] = &[
(&["star"], None, &["points"]),
(&["freeTransform"], None, &["constrain"]),
// Shape Builder Tool Options (`builder::ShapeBuilderOptions`).
(&["shapeBuilder"], None, &["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill"]),
(
&["shapeBuilder"],
None,
&["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill", "gapDetection", "gapLength"],
),
(&["artboard"], None, &["moveArt", "scaleArt"]),
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
(
+12
View File
@@ -803,6 +803,18 @@ impl Tool for TypeTool {
/// `commitTyping` (the caller commits the engine interaction).
fn set_option(&mut self, key: &str, v: &Value) {
match key {
// Transient selection transferred between Type and Eyedropper; never a saved setting.
"textSelection" => {
if let (Some(id), Some(a), Some(b)) = (
v.get("editing").and_then(Value::as_u64),
v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64),
v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64),
) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b))
{
self.start_editing(NodeId(id), b);
self.anchor = a;
}
}
"prevTool" | "previousTool" => {
self.prev_tool = v.as_str().map(String::from);
}
+39 -2
View File
@@ -23,12 +23,14 @@ use vectorcraft_geom::Point;
use super::paint_owner;
use crate::params::color_json;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Default)]
pub struct EyedropperTool {
/// Sampling for the selected gradient stop: the tool to switch back to afterwards.
stop: Option<String>,
/// The Type tool's highlighted range while sampling, including its selection overlay.
text_selection: crate::text::TypeTool,
/// Brush Options › Key Color (Hue Shift): sample into the dialog, then [`Self::stop`].
brush_key_color: bool,
}
@@ -125,18 +127,37 @@ impl Tool for EyedropperTool {
out
}
/// Expose a transient text range alongside gradient-stop and brush-key sampling state.
fn options(&self) -> Value {
json!({ "stop": self.stop, "brushKeyColor": self.brush_key_color })
let selected = self.text_selection.options();
let mut options = if selected.get("editing").is_some_and(|v| !v.is_null()) { selected } else { json!({}) };
if let Some(o) = options.as_object_mut() {
o.insert("stop".into(), json!(self.stop));
o.insert("brushKeyColor".into(), json!(self.brush_key_color));
}
options
}
/// Accept a stop target or the selection transferred from the Type tool.
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"stop" => self.stop = value.as_str().map(str::to_string),
"brushKeyColor" => self.brush_key_color = value.as_bool().unwrap_or(false),
"textSelection" => self.text_selection.set_option(key, value),
_ => {}
}
}
/// Drop the range when its text is removed or no longer selected.
fn after_command(&mut self, cx: &ToolContext) -> Vec<Action> {
self.text_selection.after_command(cx)
}
/// Keep the sampled character range highlighted without activating text input.
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if self.text_selection.options().get("editing").is_some_and(|v| !v.is_null()) { self.text_selection.overlays(cx) } else { vec![] }
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Eyedropper
}
@@ -148,6 +169,22 @@ mod tests {
use crate::testutil::*;
use vectorcraft_doc::Selection;
/// Upstream brush-key sampling and highlighted-text transfer keep independent state.
#[test]
fn text_range_and_brush_sampling_options_coexist() {
let mut tool = EyedropperTool::default();
tool.set_option("textSelection", &json!({"editing": 1, "anchor": 0, "caret": 4}));
tool.set_option("stop", &json!("selection"));
tool.set_option("brushKeyColor", &json!(true));
let options = tool.options();
assert_eq!((options["editing"].as_u64(), options["start"].as_u64(), options["end"].as_u64()), (Some(1), Some(0), Some(4)));
assert_eq!(options["stop"], "selection");
assert_eq!(options["brushKeyColor"], true);
tool.set_option("brushKeyColor", &json!(false));
assert_eq!(tool.options()["editing"], 1);
assert_eq!(tool.options()["brushKeyColor"], false);
}
#[test]
fn click_copies_appearance_shift_samples_color() {
let (d, id) = doc_with_rect();
+130 -3
View File
@@ -379,6 +379,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
{
upload(app, ui.ctx(), &done.img, done.key.pixel.is_some());
app.canvas.key = Some(done.key);
app.canvas.shown = Some((done.doc, done.opts));
app.perf.render_ms = done.ms;
app.canvas.last_ms = done.ms;
app.canvas.moved = done.moved > 0;
@@ -453,13 +454,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let settling = app.canvas.moved && !key.interacting;
let staying = key.interacting && app.canvas.from_worker;
let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc;
match (&mut app.canvas.worker, heavy) {
(Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }),
let changed = changed_pixels(app, &key, &doc, &opts, view, w, h);
match (&mut app.canvas.worker, heavy, changed) {
// Only the document changed since the frame shown, in a part of it: that part is
// rendered again, here, into the texture. `last_ms` stays the whole frame's.
(_, _, Some(region)) => {
let t0 = now_ms();
if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) {
let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts);
let color = color_image(&part);
let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
tex.set_partial([x0 as usize, y0 as usize], color, options);
app.canvas.moved |= app.canvas.renderer.stats.moved > 0;
}
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
app.perf.render_ms = now_ms() - t0;
}
(Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }),
_ => {
let t0 = now_ms();
let img = app.canvas.renderer.render(&doc, w, h, view, &opts);
upload(app, ui.ctx(), &img, pixel.is_some());
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
app.perf.render_ms = now_ms() - t0;
app.canvas.last_ms = app.perf.render_ms;
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
@@ -3127,6 +3145,52 @@ pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImag
egui::ColorImage::new(size, pixels)
}
/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document
/// changed since the frame shown (its key `key` but for the revision, and its render options
/// `opts`), which this thread rendered, and no frame is on its way from the worker: those it
/// changed in (`Some(None)`: none), grown to whole eighths of the frame, as long as they are at
/// most half of it. None: render the whole frame. Frames the worker renders stay
/// whole: its renderer keeps the rasters of the art's effects, this thread's doesn't.
fn changed_pixels(
app: &VectorcraftApp,
key: &CacheKey,
doc: &vectorcraft_doc::Document,
opts: &vectorcraft_render::RenderOptions,
view: Affine,
w: u32,
h: u32,
) -> Option<Option<[u32; 4]>> {
let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?;
// A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds.
if opts.mask_view.is_some() {
return None;
}
let same_view = app.canvas.key.as_ref().is_some_and(|k| *k == CacheKey { revision: k.revision, ..key.clone() });
if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) {
return None;
}
match vectorcraft_render::changed(shown, doc) {
vectorcraft_render::Changed::Nothing => Some(None),
vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) {
None => Some(None),
Some([x0, y0, x1, y1]) => {
let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h));
(u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1]))
}
},
vectorcraft_render::Changed::Everything => None,
}
}
/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a
/// frame rendered again come in few sizes, which keep the contexts the renderer keeps.
fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) {
let step = n.div_ceil(8).max(1);
let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n);
let a = a.min(n - len);
(a, a + len)
}
/// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified.
fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) {
let color = color_image(img);
@@ -4691,6 +4755,69 @@ mod tests {
(v, out.textures_delta)
}
/// An edit of one object renders the canvas again only where it changed, into its texture; a
/// change of the view renders all of it.
#[test]
fn an_edit_renders_only_its_part_of_the_canvas() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300})).unwrap();
app.ui.task_bar = false;
app.ui.view.rulers = false;
app.canvas.worker_started = true; // every frame on this thread, however long it takes
let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap();
app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap();
app.run("select.none", json!({})).unwrap();
let ctx = egui::Context::default();
// Where the canvas texture changed in a frame, and how much of it.
let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| {
let tex = app.canvas.texture.as_ref().map(|t| t.id());
let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size()));
delta.clear();
set
};
let (_, delta) = frame_output(&mut app, &ctx);
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it");
app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap();
let (_, delta) = frame_output(&mut app, &ctx);
let (pos, size) = canvas(&app, delta).expect("the edit shows");
let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap();
assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}");
// An edit with other render options than the frame shown's renders all of it.
app.session.prefs.pattern_tile_edge_color = "#ff00ff".into();
app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap();
let (_, delta) = frame_output(&mut app, &ctx);
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it");
app.run("view.setZoom", json!({"zoom": 2})).unwrap();
let (_, delta) = frame_output(&mut app, &ctx);
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it");
}
/// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the
/// mask's coverage wherever its art reaches, past its object's bounds.
#[test]
fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300})).unwrap();
app.ui.task_bar = false;
app.ui.view.rulers = false;
app.canvas.worker_started = true; // every frame on this thread, however long it takes
let rect =
|app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone();
let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0));
app.run("select.set", json!({"ids": [obj, mask]})).unwrap();
app.run("transparency.makeOpacityMask", json!({})).unwrap();
app.run("transparency.viewOpacityMask", json!({})).unwrap();
let ctx = egui::Context::default();
let (_, mut delta) = frame_output(&mut app, &ctx);
delta.clear();
app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap();
let (_, mut delta) = frame_output(&mut app, &ctx);
let tex = app.canvas.texture.as_ref().map(|t| t.id());
let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos);
delta.clear();
assert_eq!(pos, Some(None), "all of it");
}
/// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles.
#[test]
fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() {
@@ -5298,7 +5425,7 @@ mod tests {
// Deliver the actual replacement and verify that it replaces the cached key.
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
let expected = job.key.clone();
completed.send(crate::render_worker::Done { key: job.key, img, ms: 1.0, moved: 0 }).unwrap();
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap();
frame(&mut app, size, new_ppp);
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived");
}
+10
View File
@@ -633,6 +633,16 @@ mod tests {
]
}
#[test]
fn recolor_dialog_keeps_the_default_model_without_an_extra_dropdown() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("ui.recolorDialog", json!({"colors": ["#ff8000"]})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert!(!d.fields.contains_key("model"));
assert!(apply_params(d).get("model").is_none());
}
#[test]
fn merge_and_separate_rows() {
let mut r = rows();
@@ -18,7 +18,7 @@ pub const KIND: &str = "shapeBuilderOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 360.0, ..DialogSpec::FORM };
/// The options the dialog edits.
const KEYS: [&str; 5] = ["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill"];
const KEYS: [&str; 7] = ["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill", "gapDetection", "gapLength"];
/// Width of the label column.
const LABEL_W: f32 = 110.0;
@@ -37,6 +37,24 @@ pub fn open(app: &mut VectorcraftApp) -> Value {
}
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::check(ui, d, "gapDetection", tl!("Gap Detection"));
ui.add_enabled_ui(d.bool("gapDetection"), |ui| {
widgets::label_row(ui, tl!("Gap Length:"), LABEL_W, |ui| {
let lengths = vectorcraft_tools::builder::GAP_LENGTHS;
let len = d.f64("gapLength", lengths[0]);
let current = lengths.iter().position(|n| *n == len).unwrap_or(3);
let labels = [tl!("Small"), tl!("Medium"), tl!("Large"), tl!("Custom")];
if let Some(i) = widgets::dropdown(ui, "shape-builder-gap", labels[current], &labels, 120.0) {
d.fields.insert("gapLength".into(), json!(lengths.get(i).copied().unwrap_or(9.0)));
}
ui.add_enabled_ui(current == 3, |ui| {
if let Some(n) = widgets::plain_field(ui, "shape-builder-gap-length", len, "pt", 1, 64.0) {
d.fields.insert("gapLength".into(), json!(n.clamp(0.0, vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP)));
}
});
});
});
ui.add_space(8.0);
form::check(ui, d, "openFilledAsClosed", tl!("Consider Open Filled Paths as Closed"));
ui.add_space(8.0);
let sources = [("artwork", tl!("Artwork")), ("swatches", tl!("Color Swatches"))];
@@ -81,6 +99,22 @@ mod tests {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
#[test]
fn gap_options_share_the_existing_dialog_and_custom_is_capped() {
let mut a = app();
a.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap();
let text = painted(&mut a);
assert!(text.contains("Gap Detection") && text.contains("Gap Length:"), "{text}");
let d = a.ui.dialog.as_mut().unwrap();
d.fields.insert("gapDetection".into(), json!(true));
d.fields.insert("gapLength".into(), json!(1000));
super::super::confirm(&mut a).unwrap();
let opts = a.session.tool_options_of("shapeBuilder");
assert_eq!(opts["gapLength"], 72.0);
assert_eq!(opts["gapDetection"], true);
assert_eq!(opts["openFilledAsClosed"], true);
}
#[test]
fn double_clicking_the_tool_opens_its_options_and_ok_keeps_them() {
let mut app = app();
@@ -124,7 +158,7 @@ mod tests {
let o = app.session.tool_options_of("shapeBuilder");
assert_eq!(
o,
json!({"openFilledAsClosed": false, "pickColorFrom": "swatches", "cursorSwatchPreview": true, "selection": "straightLine", "highlightFill": false})
json!({"openFilledAsClosed": false, "pickColorFrom": "swatches", "cursorSwatchPreview": true, "selection": "straightLine", "highlightFill": false, "gapDetection": false, "gapLength": 3.0})
);
app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
+28 -7
View File
@@ -123,16 +123,18 @@ fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &s
if resp.clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
crate::panel_docking::legacy_tab(app, ui, id, &resp);
crate::panel_docking::legacy_tab(app, ui, id, &resp, false);
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::panel_docking::prune_collapsed(&mut app.ui);
if crate::panel_docking::show(app, ui) {
return;
}
apply_pending_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL, clamp_legacy_dock_width);
let t = Tokens::get(ui.ctx());
let shown = floating::shown_tab(&app.ui);
ui.ctx().data_mut(|d| d.insert_temp(floating::root_groups_id(), Vec::<floating::RootGroup>::new()));
// Main tabbed group, unless it is collapsed to icons or all its panels float.
let group = shown.filter(|_| !app.ui.dock_collapsed).map(|active| {
egui::Panel::right(LEGACY_DOCK_PANEL)
@@ -145,10 +147,23 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if collapse_header(ui, false) {
toggle(app, true);
}
// What a group dropped on it joins ([`floating::Drop::Root`]).
let rect = ui.available_rect_before_wrap();
let group = floating::RootGroup {
panels: floating::docked_tabs(&app.ui).map(|tab| tab.info().0).collect(),
shown: active.info().0,
rect,
strip: Rect::from_min_size(rect.min, vec2(rect.width(), 33.0)),
};
ui.ctx().data_mut(|d| d.insert_temp(floating::root_groups_id(), vec![group]));
if tab_strip(app, ui, active) {
// It floats from this frame on.
return;
}
// Collapsed to its tabs (a double-click on one).
if app.ui.docking_collapsed.contains(app.ui.dock_tab.info().0) {
return;
}
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match active {
DockTab::Properties => {
egui::ScrollArea::vertical()
@@ -220,9 +235,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a tab dragged out of
/// the strip floats on its own, and the strip right of the tabs floats the whole group. True when
/// the shown panel floats now.
/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a double-click
/// collapses the group to its tabs or expands it, a tab dragged out of the strip floats on its own,
/// and the strip right of the tabs floats the whole group. True when the shown panel floats now.
fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
let t = Tokens::get(ui.ctx());
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
@@ -248,10 +263,14 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
let id = tab.info().0;
if let Some((from, to)) = tab_reorder_on_release(strip, pointer, resp, &out.responses) {
apply_dock_tab_reorder(app, &tabs, from, to);
} else if resp.double_clicked() {
if let Err(error) = app.run("window.panel.collapse", json!({ "panel": id })) {
app.ui.status = error;
}
} else if resp.clicked() {
app.ui.dock_tab = tab;
}
crate::panel_docking::legacy_tab_in_strip(app, ui, id, resp, Some(strip));
crate::panel_docking::legacy_tab_in_strip(app, ui, id, resp, true, Some(strip));
}
if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() {
app.ui.dock_tab = tab;
@@ -398,8 +417,10 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
};
let tab = DockTab::from_id(id);
// Expanded, the group shows its panels in the dock itself; a floating panel shows in its group.
if tab.is_some() && !app.ui.dock_collapsed || floating::group_of(&app.ui, id).is_some() {
// Expanded, the group shows its panels in the dock itself, unless one was dropped on the icon
// column; a floating panel shows in its group.
let in_rail = app.ui.docking_icons.contains(id) && app.ui.docking.as_ref().is_some_and(|layout| !layout.contains(&id.to_string()));
if tab.is_some() && !app.ui.dock_collapsed && !in_rail || floating::group_of(&app.ui, id).is_some() {
return;
}
// A library panel (in a collapsed dock) is titled by its library's name.
+135 -13
View File
@@ -3,10 +3,12 @@
//! A dock tab, an icon of the icon column or a popped-out panel's title dragged out of the dock
//! floats that panel; the strip right of the dock's tabs floats the whole tabbed group. A floating
//! group moves by its title bar (or the strip right of its tabs, or the tab of a lone panel), and a
//! tab dragged out of a group floats on its own. Dropped on the dock, which lights up while the
//! pointer is over it, a group's panels go back to where they live in the dock (the tabbed group or
//! the icon column); dropped on another group's title bar or tabs, they stack with that group. The
//! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its
//! tab dragged out of a group floats on its own. Dropped on the tabs of one of the dock's groups
//! (lit up), a group's panels join that group as tabs; dropped on the upper or lower half of it,
//! they make a new group above or below it in the dock. Dropped on the icon column, which lights
//! up, they become icons there, the tabbed group's panels too; dropped anywhere else on the dock,
//! they go back to where they live in it. Dropped on another floating group's title bar or tabs,
//! they stack with that group. The × of a group puts its panels back in the dock too. The Tools panel floats the same way by its
//! title bar ([`crate::toolbar`]) and docks again on the window's left edge.
//!
//! Dropped on the upper or lower half of another group (a line shows where), a group joins it in
@@ -80,6 +82,47 @@ pub(crate) enum Drop {
Above(&'static str),
/// In the dock as a new column at this place (1: next to the icon column, counting left).
Column(u32),
/// In the dock's group showing this panel: as tabs, or as a new group above or below it.
Root(&'static str, RootZone),
/// In the icon column, as icons (`window.panel.dock {icon: true}`).
Icons,
}
/// Where a drop on one of the dock's groups puts the moved panels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RootZone {
/// With the group, as tabs.
Tab,
/// In a new group right above it.
Top,
/// In a new group right below it.
Bottom,
}
impl RootZone {
/// The `zone` of `window.panel.dock` / `window.panel.move`.
pub(crate) fn id(self) -> &'static str {
match self {
RootZone::Tab => "tab",
RootZone::Top => "top",
RootZone::Bottom => "bottom",
}
}
}
/// One of the dock's groups as last drawn (egui temp memory under [`root_groups_id`]): its panels,
/// the one it shows, its bounds and its tab strip. A group dropped on it joins it ([`Drop::Root`]).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct RootGroup {
pub panels: Vec<&'static str>,
pub shown: &'static str,
pub rect: Rect,
pub strip: Rect,
}
/// The dock's groups as last drawn, top to bottom.
pub(crate) fn root_groups_id() -> Id {
Id::new("floating-root-groups")
}
fn move_id() -> Id {
@@ -459,7 +502,31 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin
let y = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()).min(col.bottom() - 2.0);
return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0))));
}
let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?;
let shown = app.ui.dock && app.ui.screen_mode < 3;
// The icon column takes the panels as icons. (Mid-frame the dock's rect may not take it in yet.)
if let Some(icons) = zone(icons_rect_id()).filter(|_| shown) {
return Some((Drop::Icons, icons));
}
let dock = zone(dock_rect_id()).filter(|_| shown)?;
// One of the dock's groups: its tabs take the panels as tabs, its upper or lower half puts them
// in a new group above or below it. The group they came from (being dragged by a tab) takes
// nothing: the shared dock handles a tab moved within it.
let moved: Vec<&str> = std::iter::once(id).chain(ids_in(&app.ui, &moving)).collect();
let groups = ctx.data(|d| d.get_temp::<Vec<RootGroup>>(root_groups_id())).unwrap_or_default();
if let Some(group) = groups.iter().find(|g| g.rect.contains(at)) {
let anchor = Some(group.shown).filter(|p| !moved.contains(p)).or_else(|| group.panels.iter().copied().find(|p| !moved.contains(p)));
if let Some(anchor) = anchor {
if group.strip.contains(at) {
return Some((Drop::Root(anchor, RootZone::Tab), group.strip));
}
let mid = group.rect.center().y;
return Some(if at.y < mid {
(Drop::Root(anchor, RootZone::Top), Rect::from_x_y_ranges(group.rect.x_range(), group.rect.top()..=mid))
} else {
(Drop::Root(anchor, RootZone::Bottom), Rect::from_x_y_ranges(group.rect.x_range(), mid..=group.rect.bottom()))
});
}
}
// Panels that aren't tabs of the tabbed group (or all of them, with it collapsed) go back to
// their icons: light up the icon column, where they land, rather than the tabs.
let tabbed = !app.ui.dock_collapsed
@@ -492,8 +559,8 @@ fn insertion_line(ctx: &egui::Context, line: Rect) {
pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) {
match target {
Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect),
Drop::Stack(_) => tab_zone(ctx, rect),
Drop::Dock => highlight(ctx, rect),
Drop::Stack(_) | Drop::Root(..) => tab_zone(ctx, rect),
Drop::Dock | Drop::Icons => highlight(ctx, rect),
}
}
@@ -516,6 +583,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
app.ui.docking = m.original.docking;
app.ui.docking_hidden = m.original.docking_hidden;
app.ui.docking_icons = m.original.docking_icons;
app.ui.docking_collapsed = m.original.docking_collapsed;
app.ui.floating_panels = m.original.floating_panels;
app.ui.open_panel = m.original.open_panel;
app.ui.dock_tab = m.original.dock_tab;
@@ -548,7 +616,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
match target {
Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line),
Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs),
Some((Drop::Stack(_) | Drop::Root(..), tabs)) => tab_zone(ctx, tabs),
Some((_, zone)) => highlight(ctx, zone),
None => {}
}
@@ -565,7 +633,30 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
let set = set_of(&app.ui, gi);
let ids = ids_in(&app.ui, &set);
let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or(id);
// Icon panels dropped on the icon column go back to their icons, as on the rest of the dock.
let drop = match drop {
Drop::Icons if ids.iter().all(|p| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == *p)) => Drop::Dock,
drop => drop,
};
if matches!(drop, Drop::Root(..) | Drop::Icons) {
// The shared layout puts them in the dock's tree, group by group: the first where it was
// dropped, the others with it as tabs.
let leads: Vec<&'static str> = set.iter().filter_map(|&i| shown(&app.ui, i)).collect();
for (k, lead) in leads.iter().enumerate() {
let params = match (drop, leads.first()) {
(Drop::Root(anchor, zone), _) if k == 0 => json!({"panel": lead, "group": true, "anchor": anchor, "zone": zone.id()}),
(Drop::Root(..), Some(first)) => json!({"panel": lead, "group": true, "anchor": first, "zone": "tab"}),
_ => json!({"panel": lead, "group": true, "icon": true}),
};
if let Err(error) = app.run("window.panel.dock", params) {
app.ui.status = error;
break;
}
}
return;
}
match drop {
Drop::Root(..) | Drop::Icons => {}
Drop::Dock => {
dock(&mut app.ui, &ids, active);
// One that went back to its icon pops out of it, so it doesn't seem to vanish.
@@ -1384,24 +1475,55 @@ mod tests {
assert_eq!(h.dock_tabs().len(), 4);
}
/// A floating icon panel dropped on the icon column lights it up, goes back and pops out of its icon.
#[test]
fn a_panel_dropped_on_the_dock_lights_up_and_pops_out_of_its_icon() {
fn a_panel_dropped_on_the_icon_column_lights_it_up_and_pops_out_of_its_icon() {
let mut h = Harness::new();
let count = h.icons().len();
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
assert_eq!(h.groups(), [["color"]]);
// Over the tabbed group: Color has no tab there, so the icon column lights up, not the tabs.
let (dock, column) = (h.temp_rect(dock_rect_id()), h.temp_rect(icons_rect_id()));
let over = h.dock_tabs()[1].center();
let over = column.center();
h.hold(h.title("color"), over);
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Dock, column)));
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Icons, column)));
assert!(column.width() < dock.width(), "the column, not the whole dock: {column:?} {dock:?}");
// Dropped, it's back in the column and pops out of its icon rather than vanishing.
h.release(over);
assert!(h.groups().is_empty());
assert!(h.groups().is_empty(), "{}", h.app.ui.status);
assert_eq!((h.icons().len(), h.app.ui.open_panel.as_deref()), (count, Some("color")));
}
/// A floating panel dropped on the dock's tabs joins them; on the group's lower half it makes a group below.
#[test]
fn a_panel_dropped_on_the_dock_tabs_joins_them_and_on_its_lower_half_makes_a_group_below() {
let mut h = Harness::new();
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
assert_eq!(h.groups(), [["color"]]);
// Over the tabbed group's tabs: they light up, and Color joins them.
let over = h.dock_tabs()[1].center();
h.hold(h.title("color"), over);
assert!(matches!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Root(_, RootZone::Tab), _))));
h.release(over);
assert!(h.groups().is_empty());
assert_eq!(h.dock_tabs().len(), 4);
assert!(h.app.ui.open_panel.is_none());
// Color Guide on the group's lower half: a group of its own below the tabbed group.
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
assert_eq!(h.groups(), [["colorGuide"]]);
let dock = h.ctx.data(|d| d.get_temp::<Vec<RootGroup>>(root_groups_id())).unwrap()[0].rect;
let lower = pos2(dock.center().x, dock.bottom() - 60.0);
h.hold(h.title("colorGuide"), lower);
assert!(matches!(drop_target(&h.app, &h.ctx, Moving::Panel("colorGuide"), lower), Some((Drop::Root(_, RootZone::Bottom), _))));
h.release(lower);
assert!(h.groups().is_empty());
let groups = h.ctx.data(|d| d.get_temp::<Vec<RootGroup>>(root_groups_id())).unwrap();
assert_eq!(
groups.iter().map(|g| g.panels.clone()).collect::<Vec<_>>(),
[vec!["properties", "layers", "libraries", "color"], vec!["colorGuide"]]
);
assert!(groups[0].rect.bottom() <= groups[1].rect.top() + 1.0);
}
#[test]
fn a_popped_out_panel_dragged_by_its_title_floats() {
let mut h = Harness::new();
+15
View File
@@ -2342,6 +2342,7 @@
Proof Setup Настройка на пробен печат
Properties Свойства
Proportional Metrics Пропорционални метрики
Character Direction Посока на знаците
Digits Цифри
Arabic Арабски
Hindi Хинди
@@ -4474,6 +4475,7 @@ effect Soft Меко
Activate Panel Активиране на панела
Change Panel Layout Промяна на оформлението на панела
Close Panel Затваряне на панела
Collapse Panel Group Свиване на групата панели
Move Panel Преместване на панела
Group with Групиране с
Resize panels Преоразмеряване на панели
@@ -4484,6 +4486,12 @@ effect Soft Меко
@msg before must name a panel параметърът „преди“ трябва да указва панел
@msg docking action contains an unknown panel or an excessive split path действието по закачане съдържа непознат панел или прекомерен път на разделяне
@msg group must be true or false групата трябва да е истина или лъжа
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панелът е плаващ; свийте групата му с window.panel.float {panel, collapsed}
@msg panel is not in a dock group панелът не е в група на дока
@msg only a dock group above or below others collapses, not one beside another свива се само група на дока над или под други, не и такава до друга
@msg icon must be true or false icon трябва да е истина или лъжа
@msg icon only docks a panel in the icon column, with no anchor icon закачва панел само в колоната с икони, без anchor
@msg column docks a set as a dock column, with no anchor or icon column закачва набор като колона на дока, без anchor или icon
@msg move requires a destination panel преместването изисква целеви панел
@msg move requires an existing destination panel преместването изисква съществуващ целеви панел
@msg onto must name a floating panel параметърът „върху“ трябва да указва плаващ панел
@@ -5285,6 +5293,7 @@ theme Appearance Външен вид
Persistent Постоянна
Locate Library… Намиране на библиотека…
Library Panel Options Опции на панела с библиотека
Gap Length: Дължина на пропуска:
Artboard {n} of {total} Работна област {n} от {total}
Encoding and writing the file… Кодиране и записване на файла…
@plural {n} more export waiting|{n} more exports waiting Още {n} експортиране в изчакване|Още {n} експортирания в изчакване
@@ -5326,5 +5335,11 @@ theme Appearance Външен вид
Water Paper Мокра хартия
Ruby… Руби…
Ruby Руби
Ease In Плавно начало
Ease Out Плавен край
Ease In Out Плавно начало и край
Ease Out In Плавен край и начало
Ramp: Рампа:
Shift: Изместване:
Fresco… Фреска…
Fresco Фреска
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -829,6 +829,7 @@
Activate Panel Aktivovat panel
Change Panel Layout Změnit rozložení panelů
Close Panel Zavřít panel
Collapse Panel Group Sbalit skupinu panelů
Move Panel Přesunout panel
Group with Seskupit s
Resize panels Změnit velikost panelů
@@ -1393,6 +1394,7 @@ lighting Light Světlo
Leading Proklad
No Break Nezalamovat
Proportional Metrics Proporcionální metrika
Character Direction Směr znaků
Digits Číslice
Arabic Arabské
Hindi Hindské
@@ -4752,6 +4754,12 @@ imageTrace Outline Obrys
@msg before must name a panel before musí určovat panel
@msg docking action contains an unknown panel or an excessive split path akce ukotvení obsahuje neznámý panel nebo příliš dlouhou cestu rozdělení
@msg group must be true or false group musí být true nebo false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel je plovoucí; jeho skupinu sbalíte pomocí window.panel.float {panel, collapsed}
@msg panel is not in a dock group panel není ve skupině doku
@msg only a dock group above or below others collapses, not one beside another sbalit lze jen skupinu doku nad nebo pod jinými, ne vedle jiné
@msg icon must be true or false icon musí být true nebo false
@msg icon only docks a panel in the icon column, with no anchor icon jen ukotví panel ve sloupci ikon, bez anchor
@msg column docks a set as a dock column, with no anchor or icon column ukotví sadu jako sloupec doku, bez anchor nebo icon
@msg move requires a destination panel přesun vyžaduje cílový panel
@msg move requires an existing destination panel přesun vyžaduje existující cílový panel
@msg onto must name a floating panel onto musí určovat plovoucí panel
@@ -5328,6 +5336,7 @@ mark-style Japanese Japonské
@msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů).
Gap Length: Délka mezery:
Artboard {n} of {total} Pracovní plocha {n} z {total}
Encoding and writing the file… Kódování a zápis souboru…
@plural {n} more export waiting|{n} more exports waiting Čeká ještě {n} export|Čekají ještě {n} exporty|Čeká ještě {n} exportů
@@ -5369,5 +5378,11 @@ mark-style Japanese Japonské
Water Paper Mokrý papír
Ruby… Ruby…
Ruby Ruby
Ease In Pozvolný začátek
Ease Out Pozvolný konec
Ease In Out Pozvolný začátek a konec
Ease Out In Pozvolný konec a začátek
Ramp: Síla:
Shift: Posun:
Fresco… Freska…
Fresco Freska
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -761,6 +761,7 @@
Activate Panel Aktivér panel
Change Panel Layout Skift panelets layout
Close Panel Luk panel
Collapse Panel Group Skjul panelgruppe
Move Panel Flyt panel
Group with Gruppér med
Resize panels Tilpas størrelse på paneler
@@ -1311,6 +1312,7 @@ lighting Light Lys
Leading Linjeafstand
No Break Ingen ombrydning
Proportional Metrics Proportionale metrikker
Character Direction Tegnretning
Digits Cifre
Arabic Arabisk
Hindi Hindi
@@ -4450,6 +4452,12 @@ imageTrace Outline Kontur
@msg before must name a panel before skal angive et panel
@msg docking action contains an unknown panel or an excessive split path dokningshandlingen indeholder et ukendt panel eller en overdreven opdelingssti
@msg group must be true or false group skal være true eller false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panelet flyder; skjul dets gruppe med window.panel.float {panel, collapsed}
@msg panel is not in a dock group panelet er ikke i en dockgruppe
@msg only a dock group above or below others collapses, not one beside another kun en dockgruppe over eller under andre kan skjules, ikke en ved siden af en anden
@msg icon must be true or false icon skal være true eller false
@msg icon only docks a panel in the icon column, with no anchor icon docker kun et panel i ikonkolonnen, uden anchor
@msg column docks a set as a dock column, with no anchor or icon column docker et sæt som en dockkolonne, uden anchor eller icon
@msg move requires a destination panel move kræver et destinationspanel
@msg move requires an existing destination panel move kræver et eksisterende destinationspanel
@msg onto must name a floating panel onto skal angive et flydende panel
@@ -5281,6 +5289,7 @@ swatch Bright Violet Kraftig violet
Persistent Vedvarende
Locate Library… Lokalisér bibliotek…
Library Panel Options Indstillinger for bibliotekspanel
Gap Length: Hullængde:
Artboard {n} of {total} Tegneflade {n} af {total}
Encoding and writing the file… Koder og skriver filen…
@plural {n} more export waiting|{n} more exports waiting {n} eksport mere venter|{n} eksporter mere venter
@@ -5342,5 +5351,11 @@ swatch Bright Violet Kraftig violet
Water Paper Vådt papir
Ruby… Ruby…
Ruby Ruby
Ease In Langsom start
Ease Out Langsom slutning
Ease In Out Langsom start og slutning
Ease Out In Langsom slutning og start
Ramp: Styrke:
Shift: Forskydning:
Fresco… Fresko…
Fresco Fresko
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -2360,6 +2360,7 @@ mark-style Japanese Japanisch
Proof Setup Proof einrichten
Properties Eigenschaften
Proportional Metrics Proportionale Metrik
Character Direction Zeichenrichtung
Digits Ziffern
Arabic Arabisch
Hindi Hindi
@@ -4554,6 +4555,7 @@ theme Appearance Erscheinungsbild
Activate Panel Bedienfeld aktivieren
Change Panel Layout Bedienfeldanordnung ändern
Close Panel Bedienfeld schließen
Collapse Panel Group Bedienfeldgruppe minimieren
Move Panel Bedienfeld verschieben
Group with Gruppieren mit
Resize panel window Bedienfeldfenster skalieren
@@ -4564,6 +4566,12 @@ theme Appearance Erscheinungsbild
@msg before must name a panel before muss ein Bedienfeld benennen
@msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad
@msg group must be true or false group muss true oder false sein
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} Bedienfeld ist abgelöst; seine Gruppe mit window.panel.float {panel, collapsed} minimieren
@msg panel is not in a dock group Bedienfeld ist in keiner Dock-Gruppe
@msg only a dock group above or below others collapses, not one beside another nur eine Dock-Gruppe über oder unter anderen lässt sich minimieren, nicht eine daneben
@msg icon must be true or false icon muss true oder false sein
@msg icon only docks a panel in the icon column, with no anchor icon dockt ein Bedienfeld nur in der Symbolspalte an, ohne anchor
@msg column docks a set as a dock column, with no anchor or icon column dockt einen Satz als Dock-Spalte an, ohne anchor oder icon
@msg move requires a destination panel Das Verschieben erfordert ein Zielbedienfeld
@msg move requires an existing destination panel Das Verschieben erfordert ein vorhandenes Zielbedienfeld
@msg onto must name a floating panel onto muss ein schwebendes Bedienfeld benennen
@@ -4908,6 +4916,7 @@ effect Soft Weich
@msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel).
Gap Length: Lückenlänge:
Artboard {n} of {total} Zeichenfläche {n} von {total}
Encoding and writing the file… Datei wird codiert und geschrieben…
@plural {n} more export waiting|{n} more exports waiting {n} weiterer Export wartet|{n} weitere Exporte warten
@@ -4950,5 +4959,11 @@ effect Soft Weich
Water Paper Feuchtes Papier
Ruby… Ruby…
Ruby Ruby
Ease In Langsam starten
Ease Out Langsam enden
Ease In Out Langsam starten und enden
Ease Out In Langsam enden und starten
Ramp: Stärke:
Shift: Verschiebung:
Fresco… Fresko…
Fresco Fresko
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -1738,6 +1738,7 @@
Proof Colors Χρώματα δοκιμίου
Proof Setup Ρύθμιση δοκιμίου
Properties Ιδιότητες
Character Direction Κατεύθυνση χαρακτήρων
Digits Ψηφία
Punctuation Στίξη
Puppet Warp Παραμόρφωση με σημεία ελέγχου
@@ -2229,6 +2230,7 @@ effect Soft Απαλό
Activate Panel Ενεργοποίηση πάνελ
Change Panel Layout Αλλαγή διάταξης πάνελ
Close Panel Κλείσιμο πάνελ
Collapse Panel Group Σύμπτυξη ομάδας πάνελ
Move Panel Μετακίνηση πάνελ
Group with Ομαδοποίηση με
Resize panels Αλλαγή μεγέθους πάνελ
@@ -4469,6 +4471,12 @@ theme Appearance Εμφάνιση
@msg before must name a panel το before πρέπει να ορίζει πίνακα
@msg docking action contains an unknown panel or an excessive split path η ενέργεια σύνδεσης περιέχει άγνωστο πίνακα ή υπερβολικά βαθιά διαδρομή διαίρεσης
@msg group must be true or false το group πρέπει να είναι true ή false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} Το πάνελ αιωρείται· συμπτύξτε την ομάδα του με window.panel.float {panel, collapsed}
@msg panel is not in a dock group Το πάνελ δεν βρίσκεται σε ομάδα αγκύρωσης
@msg only a dock group above or below others collapses, not one beside another συμπτύσσεται μόνο ομάδα αγκύρωσης πάνω ή κάτω από άλλες, όχι δίπλα σε άλλη
@msg icon must be true or false το icon πρέπει να είναι true ή false
@msg icon only docks a panel in the icon column, with no anchor το icon αγκυρώνει πάνελ μόνο στη στήλη εικονιδίων, χωρίς anchor
@msg column docks a set as a dock column, with no anchor or icon το column αγκυρώνει ένα σύνολο ως στήλη αγκύρωσης, χωρίς anchor ή icon
@msg move requires a destination panel η μετακίνηση απαιτεί πίνακα προορισμού
@msg move requires an existing destination panel η μετακίνηση απαιτεί υπάρχοντα πίνακα προορισμού
@msg onto must name a floating panel το onto πρέπει να ορίζει αιωρούμενο πίνακα
@@ -5280,6 +5288,7 @@ effect Points Σημεία
@msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel).
Gap Length: Μήκος κενού:
Artboard {n} of {total} Πίνακας σχεδίασης {n} από {total}
Encoding and writing the file… Κωδικοποίηση και εγγραφή του αρχείου…
@plural {n} more export waiting|{n} more exports waiting {n} ακόμη εξαγωγή σε αναμονή|{n} ακόμη εξαγωγές σε αναμονή
@@ -5321,5 +5330,11 @@ effect Points Σημεία
Water Paper Βρεγμένο χαρτί
Ruby… Ruby…
Ruby Ruby
Ease In Ομαλή αρχή
Ease Out Ομαλό τέλος
Ease In Out Ομαλή αρχή και τέλος
Ease Out In Ομαλό τέλος και αρχή
Ramp: Ένταση:
Shift: Μετατόπιση:
Fresco… Νωπογραφία…
Fresco Νωπογραφία
Can't render this file because it contains an unexpected character in line 2040 and column 19.
+15
View File
@@ -2394,6 +2394,7 @@
Proof Setup Ajuste de prueba
Properties Propiedades
Proportional Metrics Métrica proporcional
Character Direction Dirección de caracteres
Digits Dígitos
Arabic Árabes
Hindi Hindi
@@ -4557,6 +4558,7 @@ axis Both Ambos
Activate Panel Activar panel
Change Panel Layout Cambiar distribución de paneles
Close Panel Cerrar panel
Collapse Panel Group Contraer grupo de paneles
Move Panel Mover panel
Group with Agrupar con
Resize panels Cambiar tamaño de los paneles
@@ -4567,6 +4569,12 @@ axis Both Ambos
@msg before must name a panel before debe indicar un panel
@msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga
@msg group must be true or false group debe ser true o false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} el panel flota; contraiga su grupo con window.panel.float {panel, collapsed}
@msg panel is not in a dock group el panel no está en un grupo del acoplamiento
@msg only a dock group above or below others collapses, not one beside another solo se contrae un grupo del acoplamiento situado encima o debajo de otros, no al lado
@msg icon must be true or false icon debe ser true o false
@msg icon only docks a panel in the icon column, with no anchor icon solo acopla un panel en la columna de iconos, sin anchor
@msg column docks a set as a dock column, with no anchor or icon column acopla un conjunto como columna del acoplamiento, sin anchor ni icon
@msg move requires a destination panel Para mover se requiere un panel de destino
@msg move requires an existing destination panel Para mover se requiere un panel de destino existente
@msg onto must name a floating panel onto debe indicar un panel flotante
@@ -5270,6 +5278,7 @@ mark-style Japanese Japonesas
@msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo).
Gap Length: Longitud del hueco:
Artboard {n} of {total} Mesa de trabajo {n} de {total}
Encoding and writing the file… Codificando y escribiendo el archivo…
@plural {n} more export waiting|{n} more exports waiting {n} exportación más en espera|{n} exportaciones más en espera
@@ -5311,5 +5320,11 @@ mark-style Japanese Japonesas
Water Paper Papel mojado
Ruby… Ruby…
Ruby Ruby
Ease In Entrada suave
Ease Out Salida suave
Ease In Out Entrada y salida suaves
Ease Out In Salida y entrada suaves
Ramp: Intensidad:
Shift: Desplazamiento:
Fresco… Fresco…
Fresco Fresco
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -812,6 +812,7 @@
Activate Panel Aktivoi paneeli
Change Panel Layout Muuta paneelien asettelua
Close Panel Sulje paneeli
Collapse Panel Group Kutista paneeliryhmä
Move Panel Siirrä paneelia
Group with Ryhmitä kohteen kanssa
Resize panels Muuta paneelien kokoa
@@ -1368,6 +1369,7 @@ lighting Light Valo
Leading Riviväli
No Break Ei katkoa
Proportional Metrics Suhteelliset metriikat
Character Direction Merkkien suunta
Digits Numerot
Arabic Arabia
Hindi Hindi
@@ -4661,6 +4663,12 @@ imageTrace Outline Ääriviiva
@msg before must name a panel before-arvon on nimettävä paneeli
@msg docking action contains an unknown panel or an excessive split path telakointitoiminto sisältää tuntemattoman paneelin tai liian pitkän jakopolun
@msg group must be true or false group-arvon on oltava true tai false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} paneeli kelluu; kutista sen ryhmä komennolla window.panel.float {panel, collapsed}
@msg panel is not in a dock group paneeli ei ole telakkaryhmässä
@msg only a dock group above or below others collapses, not one beside another vain muiden ylä- tai alapuolella oleva telakkaryhmä kutistuu, ei toisen vieressä oleva
@msg icon must be true or false icon-arvon on oltava true tai false
@msg icon only docks a panel in the icon column, with no anchor icon telakoi paneelin vain kuvakesarakkeeseen, ilman anchor-arvoa
@msg column docks a set as a dock column, with no anchor or icon column telakoi joukon telakkasarakkeeksi, ilman anchor- tai icon-arvoa
@msg move requires a destination panel siirto vaatii kohdepaneelin
@msg move requires an existing destination panel siirto vaatii olemassa olevan kohdepaneelin
@msg onto must name a floating panel onto-arvon on nimettävä kelluva paneeli
@@ -5292,6 +5300,7 @@ mark-style Japanese Japanilainen
@msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä).
Gap Length: Aukon pituus:
Artboard {n} of {total} Piirtoalusta {n}/{total}
Encoding and writing the file… Tiedostoa koodataan ja kirjoitetaan…
@plural {n} more export waiting|{n} more exports waiting {n} vienti odottaa|{n} vientiä odottaa
@@ -5400,5 +5409,11 @@ swatch Bright Violet Kirkas violetti
Water Paper Märkä paperi
Ruby… Ruby…
Ruby Ruby
Ease In Hidas alku
Ease Out Hidas loppu
Ease In Out Hidas alku ja loppu
Ease Out In Hidas loppu ja alku
Ramp: Voimakkuus:
Shift: Siirtymä:
Fresco… Fresko…
Fresco Fresko
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -2391,6 +2391,7 @@
Proof Setup Format d’épreuve
Properties Propriétés
Proportional Metrics Métrique proportionnelle
Character Direction Direction des caractères
Digits Chiffres
Arabic Arabes
Hindi Hindi
@@ -4569,6 +4570,7 @@ effect Soft Doux
Activate Panel Activer le panneau
Change Panel Layout Modifier la disposition des panneaux
Close Panel Fermer le panneau
Collapse Panel Group Réduire le groupe de panneaux
Move Panel Déplacer le panneau
Group with Regrouper avec
Resize panels Redimensionner les panneaux
@@ -4579,6 +4581,12 @@ effect Soft Doux
@msg before must name a panel before doit désigner un panneau
@msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long
@msg group must be true or false group doit être true ou false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} le panneau est flottant ; réduisez son groupe avec window.panel.float {panel, collapsed}
@msg panel is not in a dock group le panneau n’est dans aucun groupe du dock
@msg only a dock group above or below others collapses, not one beside another seul un groupe du dock situé au-dessus ou au-dessous d’autres se réduit, pas un groupe à côté
@msg icon must be true or false icon doit être true ou false
@msg icon only docks a panel in the icon column, with no anchor icon ne fait qu’ancrer un panneau dans la colonne d’icônes, sans anchor
@msg column docks a set as a dock column, with no anchor or icon column ancre un ensemble comme colonne du dock, sans anchor ni icon
@msg move requires a destination panel Le déplacement nécessite un panneau de destination
@msg move requires an existing destination panel Le déplacement nécessite un panneau de destination existant
@msg onto must name a floating panel onto doit désigner un panneau flottant
@@ -5327,6 +5335,7 @@ theme Appearance Apparence
@msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus).
Gap Length: Longueur de lacune :
Artboard {n} of {total} Plan de travail {n} sur {total}
Encoding and writing the file… Encodage et écriture du fichier…
@plural {n} more export waiting|{n} more exports waiting {n} autre exportation en attente|{n} autres exportations en attente
@@ -5368,5 +5377,11 @@ theme Appearance Apparence
Water Paper Papier humide
Ruby… Ruby…
Ruby Ruby
Ease In Accélération progressive
Ease Out Décélération progressive
Ease In Out Accélération et décélération
Ease Out In Décélération et accélération
Ramp: Intensité :
Shift: Décalage :
Fresco… Fresque…
Fresco Fresque
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -641,6 +641,7 @@
Close All Összes bezárása
Close Library Könyvtár bezárása
Close Panel Panel bezárása
Collapse Panel Group Panelcsoport összecsukása
Close Path Útvonal lezárása
Close paths when ends are within: Útvonalak lezárása, ha a végek távolsága legfeljebb:
Clumped Csomós
@@ -981,6 +982,7 @@
Diffuse Glow Szórt ragyogás
Diffuse Glow… Szórt ragyogás…
Diffusion Diffúziós
Character Direction Karakterirány
Digits Számjegyek
Dim Copies to: Másolatok halványítása:
Dim Images to: Képek halványítása:
@@ -4595,6 +4597,12 @@ imageTrace Outline Körvonal
@msg give the path `id` and the width point `index` add meg az útvonal `id` és a szélességi pont `index` értékét
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it a színátmenet nincs megadva a fájlban: „{name}”; helyette egyszínű kitöltés áll
@msg group must be true or false a group értéke true vagy false lehet
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} a panel lebeg; a csoportját a window.panel.float {panel, collapsed} paranccsal csukhatja össze
@msg panel is not in a dock group a panel nincs dokkolt csoportban
@msg only a dock group above or below others collapses, not one beside another csak a többi fölötti vagy alatti dokkolt csoport csukható össze, a mellettük lévő nem
@msg icon must be true or false az icon értéke true vagy false lehet
@msg icon only docks a panel in the icon column, with no anchor az icon csak az ikonoszlopba dokkol egy panelt, anchor nélkül
@msg column docks a set as a dock column, with no anchor or icon a column egy készletet dokkoszlopként dokkol, anchor és icon nélkül
@msg guides are locked a segédvonalak zárolva vannak
@msg handle must be in or out handle: in vagy out kell
@msg image '{key}' has no pixels and was left out a kép kimaradt, mert nincsenek pixelei: '{key}'
@@ -5314,6 +5322,7 @@ imageTrace Outline Körvonal
@msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel).
Gap Length: Rés hossza:
Artboard {n} of {total} Rajztábla: {n} / {total}
Encoding and writing the file… A fájl kódolása és írása…
@plural {n} more export waiting|{n} more exports waiting Még {n} exportálás várakozik
@@ -5355,5 +5364,11 @@ imageTrace Outline Körvonal
Water Paper Nedves papír
Ruby… Ruby…
Ruby Ruby
Ease In Lassú kezdés
Ease Out Lassú befejezés
Ease In Out Lassú kezdés és befejezés
Ease Out In Lassú befejezés és kezdés
Ramp: Erősség:
Shift: Eltolás:
Fresco… Freskó…
Fresco Freskó
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -761,6 +761,7 @@
Activate Panel Aktifkan panel
Change Panel Layout Ubah tata letak panel
Close Panel Tutup panel
Collapse Panel Group Ciutkan grup panel
Move Panel Pindahkan panel
Group with Grupkan dengan
Resize panels Ubah ukuran panel
@@ -1311,6 +1312,7 @@ lighting Light Cahaya
Leading Leading
No Break Tanpa pemenggalan
Proportional Metrics Metrik proporsional
Character Direction Arah karakter
Digits Digit
Arabic Arab
Hindi Hindi
@@ -4450,6 +4452,12 @@ imageTrace Outline Kontur
@msg before must name a panel before harus menyebutkan panel
@msg docking action contains an unknown panel or an excessive split path tindakan docking berisi panel tidak dikenal atau jalur pemisahan berlebihan
@msg group must be true or false group harus true atau false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel mengambang; ciutkan grupnya dengan window.panel.float {panel, collapsed}
@msg panel is not in a dock group panel tidak berada dalam grup dok
@msg only a dock group above or below others collapses, not one beside another hanya grup dok di atas atau di bawah grup lain yang bisa diciutkan, bukan yang berada di sampingnya
@msg icon must be true or false icon harus true atau false
@msg icon only docks a panel in the icon column, with no anchor icon hanya menambatkan panel di kolom ikon, tanpa anchor
@msg column docks a set as a dock column, with no anchor or icon column menambatkan satu set sebagai kolom dok, tanpa anchor atau icon
@msg move requires a destination panel move memerlukan panel tujuan
@msg move requires an existing destination panel move memerlukan panel tujuan yang sudah ada
@msg onto must name a floating panel onto harus menyebutkan panel mengambang
@@ -5281,6 +5289,7 @@ swatch Bright Violet Violet cerah
Persistent Persisten
Locate Library… Temukan pustaka…
Library Panel Options Opsi panel pustaka
Gap Length: Panjang celah:
Artboard {n} of {total} Artboard {n} dari {total}
Encoding and writing the file… Mengodekan dan menulis berkas…
@plural {n} more export waiting|{n} more exports waiting {n} ekspor lagi menunggu
@@ -5342,5 +5351,11 @@ swatch Bright Violet Violet cerah
Water Paper Kertas Basah
Ruby… Ruby…
Ruby Ruby
Ease In Awal Lambat
Ease Out Akhir Lambat
Ease In Out Awal dan Akhir Lambat
Ease Out In Akhir dan Awal Lambat
Ramp: Kekuatan:
Shift: Pergeseran:
Fresco… Fresko…
Fresco Fresko
Can't render this file because it contains an unexpected character in line 3811 and column 19.
+15
View File
@@ -2425,6 +2425,7 @@ mark-style Japanese Giapponese
Proof Setup Impostazione prova
Properties Proprietà
Proportional Metrics Metrica proporzionale
Character Direction Direzione caratteri
Digits Cifre
Arabic Arabe
Hindi Hindi
@@ -4599,6 +4600,7 @@ effect Regular Normale
Activate Panel Attiva pannello
Change Panel Layout Modifica disposizione dei pannelli
Close Panel Chiudi pannello
Collapse Panel Group Comprimi gruppo di pannelli
Move Panel Sposta pannello
Group with Raggruppa con
Resize panels Ridimensiona pannelli
@@ -4609,6 +4611,12 @@ effect Regular Normale
@msg before must name a panel before deve indicare un pannello
@msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo
@msg group must be true or false group deve essere true o false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} il pannello è mobile; comprimi il suo gruppo con window.panel.float {panel, collapsed}
@msg panel is not in a dock group il pannello non è in un gruppo del dock
@msg only a dock group above or below others collapses, not one beside another si comprime solo un gruppo del dock sopra o sotto altri, non uno accanto
@msg icon must be true or false icon deve essere true o false
@msg icon only docks a panel in the icon column, with no anchor icon ancora un pannello solo nella colonna delle icone, senza anchor
@msg column docks a set as a dock column, with no anchor or icon column ancora un insieme come colonna del dock, senza anchor né icon
@msg move requires a destination panel Lo spostamento richiede un pannello di destinazione
@msg move requires an existing destination panel Lo spostamento richiede un pannello di destinazione esistente
@msg onto must name a floating panel onto deve indicare un pannello mobile
@@ -4942,6 +4950,7 @@ lighting Light Luce
@msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel).
Gap Length: Lunghezza lacuna:
Artboard {n} of {total} Tavola da disegno {n} di {total}
Encoding and writing the file… Codifica e scrittura del file…
@plural {n} more export waiting|{n} more exports waiting {n} altra esportazione in attesa|{n} altre esportazioni in attesa
@@ -4984,5 +4993,11 @@ lighting Light Luce
Water Paper Carta bagnata
Ruby… Ruby…
Ruby Ruby
Ease In Inizio graduale
Ease Out Fine graduale
Ease In Out Inizio e fine graduali
Ease Out In Fine e inizio graduali
Ramp: Intensità:
Shift: Spostamento:
Fresco… Affresco…
Fresco Affresco
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -39,6 +39,7 @@
ICF Top/Right 平均字面の上 / 右
ICF Bottom/Left 平均字面の下 / 左
Proportional Metrics プロポーショナルメトリクス
Character Direction 文字の方向
Digits 数字
Arabic アラビア数字
Hindi ヒンディー数字
@@ -4638,6 +4639,7 @@ effect Vertical 垂直
Activate Panel パネルをアクティブにする
Change Panel Layout パネルの配置を変更
Close Panel パネルを閉じる
Collapse Panel Group パネルグループを折りたたむ
Move Panel パネルを移動
Group with グループ化先
Resize panels パネルのサイズを変更
@@ -4649,6 +4651,12 @@ effect Vertical 垂直
@msg before must name a panel before にパネルを指定してください
@msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています
@msg group must be true or false group は true または false にしてください
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} パネルはフローティング中です。グループは window.panel.float {panel, collapsed} で折りたたんでください
@msg panel is not in a dock group パネルはドックのグループにありません
@msg only a dock group above or below others collapses, not one beside another 折りたためるのは他のグループの上下にあるドックのグループだけで、横に並んだグループは折りたためません
@msg icon must be true or false icon は true または false である必要があります
@msg icon only docks a panel in the icon column, with no anchor icon はパネルをアイコン列にのみドッキングします(anchor なし)
@msg column docks a set as a dock column, with no anchor or icon column はセットをドック列としてドッキングします(anchor・icon なし)
@msg move requires a destination panel 移動先のパネルを指定してください
@msg move requires an existing destination panel 移動先には既存のパネルを指定してください
@msg onto must name a floating panel onto にフローティングパネルを指定してください
@@ -4982,6 +4990,7 @@ lighting Light ライト
@msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。
Gap Length: ギャップの長さ:
Artboard {n} of {total} アートボード {n} / {total}
Encoding and writing the file… ファイルをエンコードして書き込み中…
@plural {n} more export waiting|{n} more exports waiting 待機中の書き出し: あと {n} 件
@@ -5031,5 +5040,11 @@ lighting Light ライト
Yonroku-ban 四六判
Ruby… ルビ…
Ruby ルビ
Ease In イーズイン
Ease Out イーズアウト
Ease In Out イーズインアウト
Ease Out In イーズアウトイン
Ramp: 強さ:
Shift: シフト:
Fresco… フレスコ…
Fresco フレスコ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -703,6 +703,7 @@
Activate Panel პანელის გააქტიურება
Change Panel Layout პანელების განლაგების შეცვლა
Close Panel პანელის დახურვა
Collapse Panel Group პანელების ჯგუფის აკეცვა
Move Panel პანელის გადაადგილება
Group with ერთად დაჯგუფება
Resize panels პანელების ზომის შეცვლა
@@ -1226,6 +1227,7 @@ lighting Light სინათლე
Leading სტრიქონთა დაშორება
No Break გადატანის გარეშე
Proportional Metrics პროპორციული მეტრიკა
Character Direction სიმბოლოების მიმართულება
Digits ციფრები
Arabic არაბული
Hindi ჰინდი
@@ -4201,6 +4203,12 @@ effect Regular ჩვეულებრივი
@msg before must name a panel before უნდა ასახელებდეს პანელს
@msg docking action contains an unknown panel or an excessive split path მიმაგრების მოქმედება შეიცავს უცნობ პანელს ან ზედმეტად გრძელ გაყოფის გზას
@msg group must be true or false group უნდა იყოს true ან false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} პანელი მცურავია; მისი ჯგუფი აკეცეთ window.panel.float {panel, collapsed}-ით
@msg panel is not in a dock group პანელი არ არის დოკის ჯგუფში
@msg only a dock group above or below others collapses, not one beside another იკეცება მხოლოდ სხვების ზემოთ ან ქვემოთ მდებარე დოკის ჯგუფი, არა სხვის გვერდით მდებარე
@msg icon must be true or false icon უნდა იყოს true ან false
@msg icon only docks a panel in the icon column, with no anchor icon პანელს მხოლოდ ხატულების სვეტში ამაგრებს, anchor-ის გარეშე
@msg column docks a set as a dock column, with no anchor or icon column ნაკრებს დოკის სვეტად ამაგრებს, anchor-ის ან icon-ის გარეშე
@msg move requires a destination panel move-ს სჭირდება სამიზნე პანელი
@msg move requires an existing destination panel move-ს სჭირდება არსებული სამიზნე პანელი
@msg onto must name a floating panel onto უნდა ასახელებდეს მცურავ პანელს
@@ -5230,6 +5238,7 @@ imageTrace Outline კონტური
# Select by Color (select.color)
Select by Color მონიშვნა ფერის მიხედვით
@msg thumbnail: no page {n} (the file has 1) thumbnail: გვერდი {n} არ არსებობს (ფაილს 1 გვერდი აქვს)
Gap Length: ღრეჩოს სიგრძე:
Smudge Stick… დასაზელი ჯოხი…
Smudge Stick დასაზელი ჯოხი
Sponge… ღრუბელი…
@@ -5279,5 +5288,11 @@ imageTrace Outline კონტური
selects several, or draws one inside an artboard) რამდენიმეს მონიშნავს ან ახალს სამუშაო დაფის შიგნით ხატავს)
Ruby… რუბი…
Ruby რუბი
Ease In ნელი დასაწყისი
Ease Out ნელი დასასრული
Ease In Out ნელი დასაწყისი და დასასრული
Ease Out In ნელი დასასრული და დასაწყისი
Ramp: სიძლიერე:
Shift: წანაცვლება:
Fresco… ფრესკა…
Fresco ფრესკა
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -35,6 +35,7 @@
ICF Top/Right ICF 위/오른쪽
ICF Bottom/Left ICF 아래/왼쪽
Proportional Metrics 비례 메트릭
Character Direction 문자 방향
Digits 숫자
Arabic 아라비아어
Hindi 힌디어
@@ -4568,6 +4569,7 @@ effect Vertical 세로
Activate Panel 패널 활성화
Change Panel Layout 패널 레이아웃 변경
Close Panel 패널 닫기
Collapse Panel Group 패널 그룹 접기
Move Panel 패널 이동
Group with 함께 그룹화
Resize panels 패널 크기 조절
@@ -4579,6 +4581,12 @@ effect Vertical 세로
@msg before must name a panel before는 패널을 지정해야 합니다
@msg docking action contains an unknown panel or an excessive split path 도킹 동작에 알 수 없는 패널이나 지나치게 많은 분할 경로가 있습니다
@msg group must be true or false group은 true 또는 false여야 합니다
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 패널이 분리되어 있습니다. 그룹은 window.panel.float {panel, collapsed}로 접으세요
@msg panel is not in a dock group 패널이 도크 그룹에 없습니다
@msg only a dock group above or below others collapses, not one beside another 다른 그룹의 위나 아래에 있는 도크 그룹만 접을 수 있으며, 옆에 있는 그룹은 접을 수 없습니다
@msg icon must be true or false icon은 true 또는 false여야 합니다
@msg icon only docks a panel in the icon column, with no anchor icon은 anchor 없이 패널을 아이콘 열에만 도킹합니다
@msg column docks a set as a dock column, with no anchor or icon column은 anchor나 icon 없이 세트를 도크 열로 도킹합니다
@msg move requires a destination panel move에는 대상 패널이 필요합니다
@msg move requires an existing destination panel move에는 존재하는 대상 패널이 필요합니다
@msg onto must name a floating panel onto는 떠 있는 패널을 지정해야 합니다
@@ -4905,6 +4913,7 @@ lighting Light 광원
@msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀).
Gap Length: 간격 길이:
Artboard {n} of {total} 아트보드 {n} / {total}
Encoding and writing the file… 파일을 인코딩하고 쓰는 중…
@plural {n} more export waiting|{n} more exports waiting 대기 중인 내보내기 {n}개 더
@@ -4947,5 +4956,11 @@ lighting Light 광원
Water Paper 물 종이
Ruby… 루비…
Ruby 루비
Ease In 서서히 시작
Ease Out 서서히 종료
Ease In Out 서서히 시작 및 종료
Ease Out In 서서히 종료 및 시작
Ramp: 강도:
Shift: 이동:
Fresco… 프레스코…
Fresco 프레스코
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -775,6 +775,7 @@
Activate Panel Paneel activeren
Change Panel Layout Paneelindeling wijzigen
Close Panel Paneel sluiten
Collapse Panel Group Paneelgroep samenvouwen
Move Panel Paneel verplaatsen
Group with Groeperen met
Resize panels Panelen van formaat wijzigen
@@ -1325,6 +1326,7 @@ lighting Light Licht
Leading Regelafstand
No Break Niet afbreken
Proportional Metrics Proportionele metingen
Character Direction Tekenrichting
Digits Cijfers
Arabic Arabisch
Hindi Hindi
@@ -4462,6 +4464,12 @@ imageTrace Outline Contour
@msg before must name a panel before moet een paneel benoemen
@msg docking action contains an unknown panel or an excessive split path dokactie bevat een onbekend paneel of een overmatig splitspad
@msg group must be true or false group moet true of false zijn
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} paneel zweeft; vouw de groep samen met window.panel.float {panel, collapsed}
@msg panel is not in a dock group paneel staat niet in een dockgroep
@msg only a dock group above or below others collapses, not one beside another alleen een dockgroep boven of onder andere vouwt samen, niet een ernaast
@msg icon must be true or false icon moet true of false zijn
@msg icon only docks a panel in the icon column, with no anchor icon dockt een paneel alleen in de pictogramkolom, zonder anchor
@msg column docks a set as a dock column, with no anchor or icon column dockt een set als dockkolom, zonder anchor of icon
@msg move requires a destination panel move vereist een doelpaneel
@msg move requires an existing destination panel move vereist een bestaand doelpaneel
@msg onto must name a floating panel onto moet een zwevend paneel benoemen
@@ -5291,6 +5299,7 @@ swatch Bright Violet Felviolet
Typing Typen
Persistent Permanent
Library Panel Options Opties voor bibliotheekpaneel
Gap Length: Lengte van opening:
Locate Library… Bibliotheek lokaliseren…
@msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Opslaan schrijft {_1}, niet {_2}: gebruik file.saveAsTemplate om het te schrijven
@@ -5384,5 +5393,11 @@ swatch Bright Violet Felviolet
Water Paper Nat papier
Ruby… Ruby…
Ruby Ruby
Ease In Langzaam beginnen
Ease Out Langzaam eindigen
Ease In Out Langzaam beginnen en eindigen
Ease Out In Langzaam eindigen en beginnen
Ramp: Sterkte:
Shift: Verschuiving:
Fresco… Fresco…
Fresco Fresco
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -770,6 +770,7 @@
Activate Panel Aktywuj panel
Change Panel Layout Zmień układ paneli
Close Panel Zamknij panel
Collapse Panel Group Zwiń grupę paneli
Move Panel Przesuń panel
Group with Grupuj z
Resize panels Zmień rozmiar paneli
@@ -1307,6 +1308,7 @@ lighting Light Światło
Leading Interlinia
No Break Bez podziału
Proportional Metrics Metryka proporcjonalna
Character Direction Kierunek znaków
Digits Cyfry
Arabic Arabskie
Hindi Hindi
@@ -4577,6 +4579,12 @@ imageTrace Outline Kontur
@msg before must name a panel before musi wskazywać panel
@msg docking action contains an unknown panel or an excessive split path operacja dokowania zawiera nieznany panel lub zbyt długą ścieżkę podziału
@msg group must be true or false group musi mieć wartość true lub false
@msg icon must be true or false icon musi mieć wartość true lub false
@msg icon only docks a panel in the icon column, with no anchor icon tylko dokuje panel w kolumnie ikon, bez anchor
@msg column docks a set as a dock column, with no anchor or icon column dokuje zestaw jako kolumnę doku, bez anchor ani icon
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel jest pływający; zwiń jego grupę za pomocą window.panel.float {panel, collapsed}
@msg panel is not in a dock group panel nie należy do żadnej grupy doku
@msg only a dock group above or below others collapses, not one beside another zwinąć można tylko grupę doku nad lub pod innymi, a nie obok innej
@msg move requires a destination panel przeniesienie wymaga panelu docelowego
@msg move requires an existing destination panel przeniesienie wymaga istniejącego panelu docelowego
@msg onto must name a floating panel onto musi wskazywać panel pływający
@@ -5180,6 +5188,7 @@ mark-style Japanese Japońskie
@msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli).
Gap Length: Długość przerwy:
Artboard {n} of {total} Obszar kompozycji {n} z {total}
Encoding and writing the file… Kodowanie i zapisywanie pliku…
@plural {n} more export waiting|{n} more exports waiting Czeka jeszcze {n} eksport|Czekają jeszcze {n} eksporty|Czeka jeszcze {n} eksportów
@@ -5221,5 +5230,11 @@ mark-style Japanese Japońskie
Water Paper Mokry papier
Ruby… Ruby…
Ruby Ruby
Ease In Łagodny początek
Ease Out Łagodny koniec
Ease In Out Łagodny początek i koniec
Ease Out In Łagodny koniec i początek
Ramp: Siła:
Shift: Przesunięcie:
Fresco… Fresk…
Fresco Fresk
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -1976,6 +1976,7 @@
Proof Setup Configuração de Prova
Properties Propriedades
Proportional Metrics Métrica Proporcional
Character Direction Direção de Caracteres
Digits Dígitos
Arabic Arábicos
Hindi Hindi
@@ -3056,6 +3057,7 @@ imageTrace Outline Contorno
Activate Panel Ativar Painel
Change Panel Layout Alterar Layout dos Painéis
Close Panel Fechar Painel
Collapse Panel Group Recolher Grupo de Painéis
Move Panel Mover Painel
Group with Agrupar com
Resize panels Redimensionar painéis
@@ -4021,6 +4023,12 @@ axis Both Ambos
@msg before must name a panel before deve nomear um painel
@msg docking action contains an unknown panel or an excessive split path a ação de encaixe contém um painel desconhecido ou um caminho de divisão excessivo
@msg group must be true or false group deve ser true ou false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} o painel está flutuando; recolha o grupo com window.panel.float {panel, collapsed}
@msg panel is not in a dock group o painel não está em um grupo do encaixe
@msg only a dock group above or below others collapses, not one beside another só um grupo do encaixe acima ou abaixo de outros é recolhido, não um ao lado
@msg icon must be true or false icon deve ser true ou false
@msg icon only docks a panel in the icon column, with no anchor icon apenas encaixa um painel na coluna de ícones, sem anchor
@msg column docks a set as a dock column, with no anchor or icon column encaixa um conjunto como coluna do encaixe, sem anchor nem icon
@msg move requires a destination panel mover exige um painel de destino
@msg move requires an existing destination panel mover exige um painel de destino existente
@msg onto must name a floating panel onto deve nomear um painel flutuante
@@ -5245,6 +5253,7 @@ mark-style Japanese Japonesas
@msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels).
Gap Length: Comprimento da Lacuna:
Artboard {n} of {total} Mesa de Trabalho {n} de {total}
Encoding and writing the file… Codificando e gravando o arquivo…
@plural {n} more export waiting|{n} more exports waiting Mais {n} exportação aguardando|Mais {n} exportações aguardando
@@ -5286,5 +5295,11 @@ mark-style Japanese Japonesas
Water Paper Papel molhado
Ruby… Ruby…
Ruby Ruby
Ease In Entrada suave
Ease Out Saída suave
Ease In Out Entrada e saída suaves
Ease Out In Saída e entrada suaves
Ramp: Intensidade:
Shift: Deslocamento:
Fresco… Afresco…
Fresco Afresco
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -2421,6 +2421,7 @@ mark-style Japanese Японский
Proof Setup Настройка цветопробы
Properties Свойства
Proportional Metrics Пропорциональные метрики
Character Direction Направление символов
Digits Цифры
Arabic Арабские
Hindi Хинди
@@ -4595,6 +4596,7 @@ axis Both Оба
Activate Panel Активировать панель
Change Panel Layout Изменить расположение панелей
Close Panel Закрыть панель
Collapse Panel Group Свернуть группу панелей
Move Panel Переместить панель
Group with Сгруппировать с
Resize panels Изменить размер панелей
@@ -4605,6 +4607,12 @@ axis Both Оба
@msg before must name a panel before должен указывать панель
@msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений
@msg group must be true or false group должен иметь значение true или false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панель плавающая; сверните её группу через window.panel.float {panel, collapsed}
@msg panel is not in a dock group панель не находится в группе дока
@msg only a dock group above or below others collapses, not one beside another свернуть можно только группу дока над или под другими, а не рядом с ними
@msg icon must be true or false icon должен быть true или false
@msg icon only docks a panel in the icon column, with no anchor icon только закрепляет панель в столбце значков, без anchor
@msg column docks a set as a dock column, with no anchor or icon column закрепляет набор как столбец дока, без anchor и icon
@msg move requires a destination panel Для перемещения требуется целевая панель
@msg move requires an existing destination panel Для перемещения требуется существующая целевая панель
@msg onto must name a floating panel onto должен указывать плавающую панель
@@ -4938,6 +4946,7 @@ lighting Light Свет
@msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Изображение трассировано в размере {_1} × {_2} пикселей, уменьшенное с {_3} × {_4} (не более {_5} мегапикселей).
Gap Length: Длина зазора:
Artboard {n} of {total} Монтажная область {n} из {total}
Encoding and writing the file… Кодирование и запись файла…
@plural {n} more export waiting|{n} more exports waiting Ещё {n} экспорт в очереди|Ещё {n} экспорта в очереди|Ещё {n} экспортов в очереди
@@ -4980,5 +4989,11 @@ lighting Light Свет
Water Paper Мокрая бумага
Ruby… Руби…
Ruby Руби
Ease In Плавное начало
Ease Out Плавное окончание
Ease In Out Плавное начало и окончание
Ease Out In Плавное окончание и начало
Ramp: Сила:
Shift: Сдвиг:
Fresco… Фреска…
Fresco Фреска
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -2342,6 +2342,7 @@
Proof Setup Provtrycksinställningar
Properties Egenskaper
Proportional Metrics Proportionella mått
Character Direction Teckenriktning
Digits Siffror
Arabic Arabiska
Hindi Hindi
@@ -4480,6 +4481,7 @@ effect Soft Mjuk
Activate Panel Aktivera panel
Change Panel Layout Ändra panellayout
Close Panel Stäng panel
Collapse Panel Group Komprimera panelgrupp
Move Panel Flytta panel
Group with Gruppera med
Resize panels Ändra storlek på paneler
@@ -4490,6 +4492,12 @@ effect Soft Mjuk
@msg before must name a panel before måste ange en panel
@msg docking action contains an unknown panel or an excessive split path dockningsåtgärden innehåller en okänd panel eller en alltför djup delningssökväg
@msg group must be true or false group måste vara true eller false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panelen flyter; komprimera dess grupp med window.panel.float {panel, collapsed}
@msg panel is not in a dock group panelen ligger inte i en dockningsgrupp
@msg only a dock group above or below others collapses, not one beside another bara en dockningsgrupp ovanför eller under andra komprimeras, inte en bredvid en annan
@msg icon must be true or false icon måste vara true eller false
@msg icon only docks a panel in the icon column, with no anchor icon dockar bara en panel i ikonkolumnen, utan anchor
@msg column docks a set as a dock column, with no anchor or icon column dockar en uppsättning som en dockningskolumn, utan anchor eller icon
@msg move requires a destination panel flyttning kräver en målpanel
@msg move requires an existing destination panel flyttning kräver en befintlig målpanel
@msg onto must name a floating panel onto måste ange en flytande panel
@@ -5284,6 +5292,7 @@ theme Appearance Utseende
@msg too many library panels were opened in this session för många bibliotekspaneler har öppnats i den här sessionen
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar).
Gap Length: Lucklängd:
Artboard {n} of {total} Rityta {n} av {total}
Encoding and writing the file… Kodar och skriver filen…
@plural {n} more export waiting|{n} more exports waiting {n} export till väntar|{n} exporter till väntar
@@ -5325,5 +5334,11 @@ theme Appearance Utseende
Water Paper Vått papper
Ruby… Ruby…
Ruby Ruby
Ease In Långsam start
Ease Out Långsamt slut
Ease In Out Långsam start och slut
Ease Out In Långsamt slut och start
Ramp: Styrka:
Shift: Förskjutning:
Fresco… Fresk…
Fresco Fresk
Can't render this file because it contains an unexpected character in line 5 and column 25.
+15
View File
@@ -2417,6 +2417,7 @@ mark-style Japanese Японський
Proof Setup Налаштування кольоропроби
Properties Властивості
Proportional Metrics Пропорційні метрики
Character Direction Напрямок символів
Digits Цифри
Arabic Арабські
Hindi Гінді
@@ -4581,6 +4582,7 @@ axis Both Обидві
Activate Panel Активувати панель
Change Panel Layout Змінити розташування панелей
Close Panel Закрити панель
Collapse Panel Group Згорнути групу панелей
Move Panel Перемістити панель
Group with Згрупувати з
Resize panels Змінити розмір панелей
@@ -4591,6 +4593,12 @@ axis Both Обидві
@msg before must name a panel before має вказувати панель
@msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів
@msg group must be true or false group має мати значення true або false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панель від’єднана; згорніть її групу через window.panel.float {panel, collapsed}
@msg panel is not in a dock group панель не входить до групи дока
@msg only a dock group above or below others collapses, not one beside another згорнути можна лише групу дока над або під іншими, а не поруч з ними
@msg icon must be true or false icon має бути true або false
@msg icon only docks a panel in the icon column, with no anchor icon лише закріплює панель у стовпці значків, без anchor
@msg column docks a set as a dock column, with no anchor or icon column закріплює набір як стовпець дока, без anchor та icon
@msg move requires a destination panel Для переміщення потрібна цільова панель
@msg move requires an existing destination panel Для переміщення потрібна наявна цільова панель
@msg onto must name a floating panel onto має вказувати плаваючу панель
@@ -4922,6 +4930,7 @@ lighting Light Світло
@msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Зображення трасовано в розмірі {_1} × {_2} пікселів, зменшене з {_3} × {_4} (щонайбільше {_5} мегапікселів).
Gap Length: Довжина проміжку:
Artboard {n} of {total} Монтажна область {n} з {total}
Encoding and writing the file… Кодування та запис файлу…
@plural {n} more export waiting|{n} more exports waiting Ще {n} експорт в очікуванні|Ще {n} експорти в очікуванні|Ще {n} експортів в очікуванні
@@ -4964,5 +4973,11 @@ lighting Light Світло
Water Paper Мокрий папір
Ruby… Рубі…
Ruby Рубі
Ease In Плавний початок
Ease Out Плавне завершення
Ease In Out Плавний початок і завершення
Ease Out In Плавне завершення і початок
Ramp: Сила:
Shift: Зсув:
Fresco… Фреска…
Fresco Фреска
Can't render this file because it contains an unexpected character in line 733 and column 40.
@@ -55,6 +55,7 @@ Text options: {e}
`background` must be a colour
`burasagari` must be "none", "standard" or "forced"
`charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom"
`charDirection` must be "default", "leftToRight" or "rightToLeft"
`color` needs a `rule` to make the theme from
`colors` is empty
`columnType` is vertical, uniform, repeating or sliding
@@ -329,6 +330,7 @@ bad edit
bad fill colour
bad fill {v}
bad fit: {e}
bad model {other} (keep, new or document)
bad new colour {v}
bad object
bad object number
@@ -429,6 +431,7 @@ each of `items` is {{id, corners?}} with an object id, not {it}
each row needs `from`
each stop needs a position
each subpath needs an anchors array
easingType must be easeIn, easeOut, easeInOut or easeOutIn
editingData must be true or false
editingData must be true, false or "always"
edits overlap
@@ -806,6 +809,7 @@ nothing to mark
nothing to print: every artboard picked is blank
nothing to print: the document has no art that prints
object is not a path
object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object
object {_1} has lost its file's contents: relink it
object {_1} has no size
object {_1} is not a placed document
@@ -932,6 +936,7 @@ scope must be selection or all (got {other})
scribble is too long
segment does not exist
select a Shaper group
select a blend to set easing or color shift
select a graph
select a group or layer with visible, unlocked members
select a layer
@@ -1252,6 +1257,7 @@ zoom must be positive
{key} must be 0–{MAX_ADD_AROUND} pt
{key} must be Unix seconds (an integer) or null
{key} must be [x,y]
{key} must be a percentage {min}..100
{key} must be a positive number, not {v}
{key} must be an array of non-negative integer object ids
{key} must be one of {_1}
+15
View File
@@ -554,6 +554,7 @@
Close All Đóng tất cả
Close Library Đóng thư viện
Close Panel Đóng bảng
Collapse Panel Group Thu gọn nhóm bảng
Close Path Đóng đường dẫn
Close paths when ends are within: Đóng đường dẫn khi hai đầu cách nhau trong:
Clumped Dạng cụm
@@ -875,6 +876,7 @@
Differences from {preset}: Khác biệt so với {preset}:
Diffuse Glow Ánh sáng khuếch tán
Diffuse Glow… Ánh sáng khuếch tán…
Character Direction Hướng ký tự
Digits Chữ số
Dim Copies to: Làm mờ bản sao đến:
Dim Images to: Làm mờ ảnh đến:
@@ -4241,6 +4243,12 @@ theme Appearance Giao diện
@msg give the path `id` and the width point `index` hãy cung cấp `id` đường dẫn và `index` điểm chiều rộng
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it chuyển sắc “{name}” không được định nghĩa trong tệp: một màu đơn thay thế
@msg group must be true or false group phải là true hoặc false
@msg icon must be true or false icon phải là true hoặc false
@msg icon only docks a panel in the icon column, with no anchor icon chỉ neo bảng vào cột biểu tượng, không có anchor
@msg column docks a set as a dock column, with no anchor or icon column neo một bộ thành cột neo, không có anchor hay icon
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} bảng đang nổi; hãy thu gọn nhóm của nó bằng window.panel.float {panel, collapsed}
@msg panel is not in a dock group bảng không thuộc nhóm nào của dock
@msg only a dock group above or below others collapses, not one beside another chỉ thu gọn được nhóm dock nằm trên hoặc dưới các nhóm khác, không phải nhóm nằm cạnh
@msg guides are locked đường căn bị khóa
@msg handle must be in or out handle phải là in hoặc out
@msg image '{key}' has no pixels and was left out ảnh '{key}' không có pixel và bị bỏ qua
@@ -4922,6 +4930,7 @@ theme Appearance Giao diện
@msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel).
Gap Length: Độ dài khoảng hở:
Artboard {n} of {total} Bảng vẽ {n} / {total}
Encoding and writing the file… Đang mã hóa và ghi tệp…
@plural {n} more export waiting|{n} more exports waiting Còn {n} lượt xuất đang chờ
@@ -4964,5 +4973,11 @@ theme Appearance Giao diện
Water Paper Giấy ướt
Ruby… Ruby…
Ruby Ruby
Ease In Chậm lúc đầu
Ease Out Chậm lúc cuối
Ease In Out Chậm lúc đầu và cuối
Ease Out In Chậm lúc cuối và đầu
Ramp: Cường độ:
Shift: Dịch chuyển:
Fresco… Bích họa…
Fresco Bích họa
Can't render this file because it contains an unexpected character in line 4023 and column 10.
+15
View File
@@ -2298,6 +2298,7 @@ font-script Japanese 汉字
Proof Setup 校样设置
Properties 属性
Proportional Metrics 比例度量
Character Direction 字符方向
Digits 数字
Arabic 阿拉伯数字
Hindi 印地数字
@@ -4274,6 +4275,12 @@ imageTrace Outline 轮廓
@msg before must name a panel before 必须指定面板
@msg docking action contains an unknown panel or an excessive split path 停靠操作包含未知面板或过长的拆分路径
@msg group must be true or false group 必须是 true 或 false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 面板处于浮动状态;请使用 window.panel.float {panel, collapsed} 折叠其面板组
@msg panel is not in a dock group 面板不在停放组中
@msg only a dock group above or below others collapses, not one beside another 只有位于其他组上方或下方的停放组可以折叠,并排的不行
@msg icon must be true or false icon 必须为 true 或 false
@msg icon only docks a panel in the icon column, with no anchor icon 只会把面板停放到图标栏中,不能带 anchor
@msg column docks a set as a dock column, with no anchor or icon column 将一组面板停放为停放栏,不能带 anchor 或 icon
@msg move requires a destination panel 移动需要目标面板
@msg move requires an existing destination panel 移动需要现有的目标面板
@msg onto must name a floating panel onto 必须指定浮动面板
@@ -4883,6 +4890,7 @@ effect Vertical 垂直
Activate Panel 激活面板
Change Panel Layout 更改面板布局
Close Panel 关闭面板
Collapse Panel Group 折叠面板组
Move Panel 移动面板
Group with 组合到
Resize panels 调整面板大小
@@ -5161,6 +5169,7 @@ lighting Light 光源
@msg too many library panels were opened in this session 本次会话中打开的库面板过多
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 图像已按 {_1} × {_2} 像素描摹,由 {_3} × {_4} 缩小而来(最多 {_5} 百万像素)。
Gap Length: 间隙长度:
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one 编辑原稿只能打开图像和文档文件;“{name}” 不是其中之一
@msg only http, https and mailto URLs open in the browser, not “{url}” 浏览器中只会打开 http、https 和 mailto 的 URL,不会打开 “{url}”
@@ -5208,5 +5217,11 @@ lighting Light 光源
Water Paper 水彩画纸
Ruby… 注音…
Ruby 注音
Ease In 缓入
Ease Out 缓出
Ease In Out 缓入缓出
Ease Out In 缓出缓入
Ramp: 强度:
Shift: 偏移:
Fresco… 壁画…
Fresco 壁画
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -2258,6 +2258,12 @@ file-filter Templates 範本
@msg before must name a panel before 必須指定面板
@msg docking action contains an unknown panel or an excessive split path 停駐動作包含未知的面板或過長的分割路徑
@msg group must be true or false group 必須是 true 或 false
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 面板處於浮動狀態;請使用 window.panel.float {panel, collapsed} 摺疊其面板群組
@msg panel is not in a dock group 面板不在停駐群組中
@msg only a dock group above or below others collapses, not one beside another 只有位於其他群組上方或下方的停駐群組可以摺疊,並排的不行
@msg icon must be true or false icon 必須為 true 或 false
@msg icon only docks a panel in the icon column, with no anchor icon 只會將面板停駐到圖示欄中,不能帶 anchor
@msg column docks a set as a dock column, with no anchor or icon column 會將一組面板停駐為停駐欄,不能帶 anchor 或 icon
@msg move requires a destination panel 移動需要目的地面板
@msg move requires an existing destination panel 移動需要現有的目的地面板
@msg onto must name a floating panel onto 必須指定浮動面板
@@ -3166,6 +3172,7 @@ font-script Japanese 漢字
Proof Setup 校樣設定
Properties 內容
Proportional Metrics 比例度量
Character Direction 字元方向
Digits 數字
Arabic 阿拉伯數字
Hindi 印地數字
@@ -4689,6 +4696,7 @@ effect Soft 柔和
Activate Panel 啟用面板
Change Panel Layout 變更面板配置
Close Panel 關閉面板
Collapse Panel Group 摺疊面板群組
Move Panel 移動面板
Group with 群組至
Resize panels 調整面板大小
@@ -4956,6 +4964,7 @@ lighting Light 光源
Persistent 持久
Locate Library… 尋找資料庫…
Library Panel Options 資料庫面板選項
Gap Length: 間隙長度:
Artboard {n} of {total} 工作區域 {n} / {total}
Encoding and writing the file… 正在編碼並寫入檔案…
@plural {n} more export waiting|{n} more exports waiting 另有 {n} 個匯出等待中
@@ -5003,5 +5012,11 @@ lighting Light 光源
Water Paper 水彩畫紙
Ruby… 注音…
Ruby 注音
Ease In 緩入
Ease Out 緩出
Ease In Out 緩入緩出
Ease Out In 緩出緩入
Ramp: 強度:
Shift: 偏移:
Fresco… 壁畫…
Fresco 壁畫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -4
View File
@@ -410,16 +410,17 @@ fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String {
/// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`,
/// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s
/// place. A path that still ends in the extension of the name the panel suggested (`suggested`)
/// gets the picked type's extension instead. Without a picked type, or with one the panel did not
/// place. A path that still ends in the extension of the name the panel suggested (`suggested`),
/// in any letter case, gets the picked type's extension instead, unless that extension is one of
/// the picked type's; such a path stays as typed. Without a picked type, or with one the panel did not
/// offer, [`with_save_extension`] with `f`.
fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String {
let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS };
let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else {
return with_save_extension(path, f, mode);
};
let has = std::path::Path::new(path).extension();
if has.is_some() && has == std::path::Path::new(suggested).extension() {
let has = fileio::extension(path);
if !has.is_empty() && has == fileio::extension(suggested) && !g.extensions.contains(&has.as_str()) {
return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned();
}
with_save_extension(path, g, mode)
+5
View File
@@ -321,6 +321,10 @@ pub struct CanvasCache {
pub renderer: vectorcraft_render::Renderer,
pub texture: Option<egui::TextureHandle>,
pub key: Option<CacheKey>,
/// The document the frame shown was rendered from, and the options it was rendered with, with
/// [`Self::key`]: when only the document changes, the canvas renders again only where it
/// changed ([`vectorcraft_render::changed`]).
pub shown: Option<(std::sync::Arc<vectorcraft_doc::Document>, vectorcraft_render::RenderOptions)>,
pub last_ms: f64,
/// The frame shown drew raster effects from rasters made before their objects moved, or at a
/// drag's lower resolution ([`vectorcraft_render::FrameStats::moved`]): the next frame outside
@@ -565,6 +569,7 @@ impl VectorcraftApp {
renderer: vectorcraft_render::Renderer::new(),
texture: None,
key: None,
shown: None,
last_ms: 0.0,
moved: false,
from_worker: false,
+19 -3
View File
@@ -459,6 +459,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
),
("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"),
("window.panel.close", "Close Panel", "", "{panel} hide a panel"),
(
"window.panel.collapse",
"Collapse Panel Group",
"",
"{panel, collapsed?: bool} show the dock group holding a panel as its tabs only (or its panel again), as a double-click on its tab does; the panel becomes the group's shown one. Without `collapsed` it toggles. Floating groups collapse through window.panel.float {panel, collapsed}. Returns {panel, collapsed}",
),
(
"window.panel.float",
"Float Panel",
@@ -469,7 +475,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"window.panel.dock",
"Dock Panel",
"",
"{panel: id or \"tools\", group?: bool, column?: true or n} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}",
"{panel: id or \"tools\", group?: bool, column?: true or n, anchor?: id, zone?: \"tab\" | \"top\" | \"bottom\" | \"left\" | \"right\" | \"center\", before?: id, icon?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `anchor`, in the dock group holding that panel instead: `zone` \"tab\" (the default) as a tab of it (before the `before` panel's tab), as dropping it on that group's tabs does, \"top\" / \"bottom\" as a new group above / below it, as dropping it on that group's upper / lower half does (\"left\", \"right\" and \"center\" split or join it likewise); with `icon: true` (no `anchor`), as icons in the icon column, the tabbed group's panels too, as dropping it on the icon column does: it lives there from then on and pops out of its icon; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}, or with `anchor` or `icon: true` (the shared layout) {panel, floating, layout} and no `column`",
),
(
"window.collapseDock",
@@ -478,8 +484,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"{collapsed?: bool} collapse the dock's Properties | Layers | Libraries group to icons (true), expand it (false) or toggle (omitted), as the double arrow at the top of the dock does; returns the new state",
),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"),
("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"),
(
"window.workspace",
"Workspace",
"",
"{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)",
),
(
"window.workspace.reset",
"Reset Essentials",
"",
"{} reset the current workspace to the way it was saved, dropping the layout changes made in it",
),
("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"),
("window.workspace.manage", "Manage Workspaces…", "", "{}"),
("window.workspace.delete", "Delete Workspace", "", "{name}"),
+381 -40
View File
@@ -7,10 +7,12 @@ use crate::VectorcraftApp;
#[derive(Clone, Default)]
struct LegacyDrag {
panel: String,
layout: Option<Layout<String>>,
open_panel: Option<String>,
floating: Vec<crate::state::FloatingPanels>,
icons: std::collections::BTreeSet<String>,
collapsed: std::collections::BTreeSet<String>,
}
#[derive(Clone)]
@@ -18,6 +20,8 @@ struct RootGroupDrag {
panel: String,
layout: Layout<String>,
hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
collapsed: std::collections::BTreeSet<String>,
open_panel: Option<String>,
generation: u64,
}
@@ -29,6 +33,7 @@ fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
app.ui.floating_panels = previous.floating;
app.ui.docking = previous.layout;
app.ui.docking_icons = previous.icons;
app.ui.docking_collapsed = previous.collapsed;
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("vectorcraft-panel-docking"));
true
} else {
@@ -231,7 +236,18 @@ fn placement(params: &Value) -> Result<Placement<String>, String> {
}
pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if id == "window.panel.collapse" {
return Some(collapse(app, params));
}
// Docking into the dock's tree or its icon column is the shared layout's, whatever set the
// panel floats in. A dock column is the native sets', so it doesn't combine with either.
let to_icons = params.get("icon").is_some_and(|icon| icon != &Value::Bool(false));
let into_tree = id == "window.panel.dock" && (params.get("anchor").is_some() || to_icons);
if into_tree && params.get("column").is_some() {
return Some(Err("column docks a set as a dock column, with no anchor or icon".into()));
}
if matches!(id, "window.panel.float" | "window.panel.dock")
&& !into_tree
&& (params.get("above").is_some()
|| params.get("below").is_some()
|| params.get("column").is_some()
@@ -338,6 +354,16 @@ fn apply_action(app: &mut VectorcraftApp, action: Action<String>) -> Result<Valu
Action::Activate { panel } => Some(panel.clone()),
_ => None,
};
// A panel moved, floated or closed leaves its group: that group isn't collapsed any more, and
// the panel doesn't take the collapse along.
let moved = match &action {
Action::Move { panel, .. }
| Action::OpenAt { panel, .. }
| Action::Open { panel, .. }
| Action::Float { panel, .. }
| Action::Close { panel } => Some(panel.clone()),
_ => None,
};
layout.apply(action).map_err(|error| error.to_string())?;
restore_float_fallback(&mut layout, fallback)?;
if let Some((panel, location)) = returning {
@@ -348,6 +374,9 @@ fn apply_action(app: &mut VectorcraftApp, action: Action<String>) -> Result<Valu
}
app.ui.docking_icons = icons;
app.ui.docking = Some(layout);
if let Some(panel) = moved {
app.ui.docking_collapsed.remove(&panel);
}
if let Some(panel) = activated {
for group in &mut app.ui.floating_panels {
if group.panels.contains(&panel) {
@@ -360,6 +389,130 @@ fn apply_action(app: &mut VectorcraftApp, action: Action<String>) -> Result<Valu
Ok(json!({"layout": app.ui.docking}))
}
/// The panels shown by the dock's groups that can collapse to their tabs: each group of the shared
/// layout's tree that is the whole tree or above or below another (one beside another can't be
/// shorter than it), or the tabbed group of the legacy dock.
fn dock_groups_shown(ui: &crate::state::UiState) -> Vec<String> {
let Some(layout) = &ui.docking else {
return crate::floating::shown_tab(ui).map(|tab| tab.info().0.to_string()).into_iter().collect();
};
let mut out = Vec::new();
// Each node, and whether it may be shortened (the root, or in a top/bottom split).
let mut pending: Vec<(&Node<String>, bool)> = layout.root.iter().map(|root| (root, true)).collect();
while let Some((node, shortens)) = pending.pop() {
match node {
Node::Tabs { panels, active } if shortens => out.extend(panels.get(*active).cloned()),
Node::Split { axis, first, second, .. } => {
let vertical = *axis == craft_ui::layout::SplitAxis::Vertical;
pending.extend([(second.as_ref(), vertical), (first.as_ref(), vertical)]);
}
_ => {}
}
}
out
}
/// Whether the group of the shared layout's tree holding `panel` as a tab can be shortened to its
/// tabs (the whole tree, or above or below another group); `None` when `panel` isn't a tab of one
/// (it floats, or is in an accordion).
fn tab_group_shortens(layout: &Layout<String>, panel: &str) -> Option<bool> {
let mut pending: Vec<(&Node<String>, bool)> = layout.root.iter().map(|root| (root, true)).collect();
while let Some((node, shortens)) = pending.pop() {
match node {
Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return Some(shortens),
Node::Split { axis, first, second, .. } => {
let vertical = *axis == craft_ui::layout::SplitAxis::Vertical;
pending.extend([(second.as_ref(), vertical), (first.as_ref(), vertical)]);
}
_ => {}
}
}
None
}
/// Forget the collapsed groups that show another panel now (another tab clicked, or the panel
/// moved or closed): those are expanded.
pub(crate) fn prune_collapsed(ui: &mut crate::state::UiState) {
if ui.docking_collapsed.is_empty() {
return;
}
let shown = dock_groups_shown(ui);
ui.docking_collapsed.retain(|id| shown.contains(id));
}
/// `window.panel.collapse`: show the dock group holding `panel` as its tabs only, or expanded.
fn collapse(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
let collapsed = crate::menus::opt_bool(params, "collapsed")?;
if crate::floating::group_of(&app.ui, panel).is_some() {
return Err("panel floats; collapse its group with window.panel.float {panel, collapsed}".into());
}
match app.ui.docking.as_mut() {
Some(layout) => {
match tab_group_shortens(layout, panel) {
None => return Err("panel is not in a dock group".into()),
Some(false) => return Err("only a dock group above or below others collapses, not one beside another".into()),
Some(true) => {}
}
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
sync_legacy(app);
}
None => {
let tab = crate::state::DockTab::from_id(panel)
.filter(|_| crate::floating::docked_tabs(&app.ui).any(|t| t.info().0 == panel))
.ok_or("panel is not in a dock group")?;
app.ui.dock_tab = tab;
}
}
prune_collapsed(&mut app.ui);
let now = collapsed.unwrap_or(!app.ui.docking_collapsed.contains(panel));
if now {
app.ui.docking_collapsed.insert(panel.into());
} else {
app.ui.docking_collapsed.remove(panel);
}
Ok(json!({"panel": panel, "collapsed": now}))
}
/// `node` as drawn with its collapsed groups (showing a panel in `collapsed`) as tall as their
/// tabs in a top/bottom split. The paths of the splits so sized go in `fixed`, the panels of
/// collapsed groups in `members`. Also returns the node's height when it is only collapsed groups.
fn collapse_node(
node: &Node<String>,
collapsed: &std::collections::BTreeSet<String>,
(tab, gap): (f32, f32),
path: &mut Vec<bool>,
fixed: &mut Vec<Vec<bool>>,
members: &mut std::collections::BTreeSet<String>,
) -> (Node<String>, Option<f32>) {
use craft_ui::layout::{SplitAxis, SplitSize};
match node {
Node::Tabs { panels, active } if panels.get(*active).is_some_and(|id| collapsed.contains(id)) => {
members.extend(panels.iter().cloned());
(node.clone(), Some(tab))
}
Node::Split { axis, size, first, second } => {
path.push(false);
let (a, ha) = collapse_node(first, collapsed, (tab, gap), path, fixed, members);
path.pop();
path.push(true);
let (b, hb) = collapse_node(second, collapsed, (tab, gap), path, fixed, members);
path.pop();
let (sized, height) = match (axis, ha, hb) {
(SplitAxis::Vertical, Some(ha), Some(hb)) => (Some(SplitSize::FixedFirst(ha)), Some(ha + hb + gap)),
(SplitAxis::Vertical, Some(ha), None) => (Some(SplitSize::FixedFirst(ha)), None),
(SplitAxis::Vertical, None, Some(hb)) => (Some(SplitSize::FixedSecond(hb)), None),
_ => (None, None),
};
if sized.is_some() {
fixed.push(path.clone());
}
(Node::Split { axis: *axis, size: sized.unwrap_or(*size), first: Box::new(a), second: Box::new(b) }, height)
}
_ => (node.clone(), None),
}
}
fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result<Value, String> {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
if operation == "move" && params.get("before").is_some() {
@@ -386,11 +539,24 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result<V
Some(Value::Bool(value)) => *value,
Some(_) => return Err("group must be true or false".into()),
};
let to_icons = match params.get("icon") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => return Err("icon must be true or false".into()),
};
if to_icons && (operation != "dock" || params.get("anchor").or_else(|| params.get("onto")).is_some()) {
return Err("icon only docks a panel in the icon column, with no anchor".into());
}
let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] };
let mut hidden: std::collections::BTreeMap<_, _> =
app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect();
let mut icons = icon_origins(app);
let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel);
if to_icons {
// Any panel the icon column can show lives there once dropped on it, the tabbed group's
// too; the others (library panels) dock as they would without `icon`.
icons.extend(members.iter().filter(|id| crate::state::rail_panel(id).is_some()).cloned());
}
let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel) || icons.contains(panel);
if icon_panel && !layout.contains(&panel.to_string()) {
icons.insert(panel.to_string());
}
@@ -561,6 +727,12 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result<V
app.ui.docking = Some(layout);
app.ui.docking_hidden = hidden;
app.ui.docking_icons = icons;
if operation != "activate" {
app.ui.docking_collapsed.remove(panel);
for member in &members {
app.ui.docking_collapsed.remove(member);
}
}
if operation == "activate" {
for group in &mut app.ui.floating_panels {
if group.panels.iter().any(|id| id == panel) {
@@ -580,10 +752,15 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result<V
struct PanelContent<'a> {
app: &'a mut VectorcraftApp,
commands: Vec<(&'static str, Value)>,
/// The panels of groups collapsed to their tabs: no body.
shut: std::collections::BTreeSet<String>,
}
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, panel: &String) {
if self.shut.contains(panel) {
return;
}
let t = crate::theme::Tokens::get(ui.ctx());
let back = ui.max_rect();
if back.is_positive() {
@@ -631,6 +808,8 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
panel: active.clone(),
layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)),
hidden: self.app.ui.docking_hidden.clone(),
collapsed: self.app.ui.docking_collapsed.clone(),
open_panel: self.app.ui.open_panel.clone(),
generation: self.app.ui.docking_generation,
},
)
@@ -642,6 +821,74 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
}
}
/// The command that puts `panel` (with its group when `whole_group`) where a pointer drop on
/// `target` puts it.
fn drop_command(panel: &str, target: crate::floating::Drop, whole_group: bool) -> (&'static str, Value) {
use crate::floating::Drop;
match target {
Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})),
Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})),
Drop::Below(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "below": onto})),
Drop::Above(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "above": onto})),
Drop::Column(slot) => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "column": slot})),
Drop::Root(anchor, zone) => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "anchor": anchor, "zone": zone.id()})),
Drop::Icons => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "icon": true})),
}
}
/// The groups of the shared dock's tree as drawn in `content` (the rect its tree was laid out in),
/// from where its tabs were drawn: a group's tabs start at its top-left corner, and the groups of
/// a split meet where the second one's tabs start.
fn root_groups(ctx: &egui::Context, layout: &Layout<String>, content: egui::Rect, tab_height: f32) -> Vec<crate::floating::RootGroup> {
let area = egui::Id::new("vectorcraft-panel-docking");
let tab = |panel: &String| ctx.read_response(area.with(("tab", panel))).map(|response| response.rect).filter(|r| r.is_positive());
/// The top-left corner of a node's first group, from where its tabs were drawn.
fn corner(node: &Node<String>, tab: &dyn Fn(&String) -> Option<egui::Rect>) -> Option<egui::Pos2> {
match node {
Node::Tabs { panels, .. } => panels.iter().filter_map(tab).map(|r| r.min).reduce(|a, b| a.min(b)),
Node::Stack { entries } => entries.first().and_then(|entry| tab(&entry.panel)).map(|r| r.min),
Node::Split { first, .. } => corner(first, tab),
}
}
let mut out = Vec::new();
let mut pending: Vec<(&Node<String>, egui::Rect)> = layout.root.iter().map(|root| (root, content)).collect();
while let Some((node, rect)) = pending.pop() {
if out.len() >= craft_ui::docking::MAX_PANELS || !rect.is_positive() {
break;
}
match node {
Node::Tabs { panels, active } => {
let ids: Vec<&'static str> = panels.iter().filter_map(|id| normalize(id)).collect();
let Some(shown) = panels.get(*active).and_then(|id| normalize(id)).or_else(|| ids.first().copied()) else { continue };
let strip = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), (rect.top() + tab_height).min(rect.bottom())));
out.push(crate::floating::RootGroup { panels: ids, shown, rect, strip });
}
Node::Stack { entries } => {
let ids: Vec<&'static str> = entries.iter().filter_map(|entry| normalize(&entry.panel)).collect();
let Some(shown) = ids.first().copied() else { continue };
let strip = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), (rect.top() + tab_height).min(rect.bottom())));
out.push(crate::floating::RootGroup { panels: ids, shown, rect, strip });
}
Node::Split { axis, first, second, .. } => {
let Some(at) = corner(second, &tab) else { continue };
let (a, b) = match axis {
craft_ui::layout::SplitAxis::Vertical => {
let y = at.y.clamp(rect.top(), rect.bottom());
(egui::Rect::from_x_y_ranges(rect.x_range(), rect.top()..=y), egui::Rect::from_x_y_ranges(rect.x_range(), y..=rect.bottom()))
}
craft_ui::layout::SplitAxis::Horizontal => {
let x = at.x.clamp(rect.left(), rect.right());
(egui::Rect::from_x_y_ranges(rect.left()..=x, rect.y_range()), egui::Rect::from_x_y_ranges(x..=rect.right(), rect.y_range()))
}
};
pending.push((second, b));
pending.push((first, a));
}
}
}
out
}
pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
let root_key = egui::Id::new("vectorcraft-root-group-drag");
if let Some(drag) = ui.ctx().data(|data| data.get_temp::<RootGroupDrag>(root_key)) {
@@ -650,6 +897,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
} else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
app.ui.docking = Some(drag.layout);
app.ui.docking_hidden = drag.hidden;
app.ui.docking_collapsed = drag.collapsed;
app.ui.open_panel = drag.open_panel;
sync_legacy(app);
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
} else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) {
@@ -666,13 +915,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
if let Some(panel) = normalize(&drag.panel)
&& let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
let (id, params) = match target {
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})),
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})),
crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})),
crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})),
crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})),
};
let (id, params) = drop_command(panel, target, true);
if let Err(error) = app.run(id, params) {
app.ui.status = error;
}
@@ -733,6 +976,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
.collect();
crate::dock::apply_pending_dock_width(app, ui.ctx(), crate::dock::SHARED_DOCK_PANEL, crate::dock::clamp_shared_dock_width);
let shared_dock_default = crate::dock::shared_dock_default_width(&app.ui);
// Splits sized to a collapsed group's tabs: their dividers don't resize the saved layout.
let mut fixed: Vec<Vec<bool>> = Vec::new();
let mut draw = |ui: &mut egui::Ui| {
if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) {
let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed}));
@@ -775,22 +1020,62 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
style.panels_label = tl!("Panels").to_string();
style.resize_label = tl!("Resize panels").to_string();
style.resize_window_label = tl!("Resize panel window").to_string();
let mut content = PanelContent { app, commands: Vec::new() };
let output = craft_ui::docking::DockArea::new(area).show_customized(
ui,
&display,
&style,
|id| {
crate::state::all_panels()
.find(|p| p.0 == id)
.map(|p| tl!(p.1).to_string())
.or_else(|| library_label(id))
.unwrap_or_else(|| id.clone())
},
|_| craft_ui::docking::Permissions::default(),
|_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() },
&mut content,
);
// Panels set their own minimum height, so a collapsed group can be as short as its tabs.
style.min_pane = 0.0;
let mut tree = ui.available_rect_before_wrap();
let mut shut = std::collections::BTreeSet::new();
let mut drawn = display.clone();
// The height of the tree when it is only collapsed groups: drawn that tall, so no group
// takes the rest of the dock as a blank pane.
let mut only_tabs = None;
if let Some(root) = &display.root {
let (node, height) =
collapse_node(root, &app.ui.docking_collapsed, (style.tab_height, style.gap), &mut Vec::new(), &mut fixed, &mut shut);
drawn.root = Some(node);
only_tabs = height.filter(|h| h.is_finite() && *h > 0.0);
}
let short = shut.clone();
let mut content = PanelContent { app, commands: Vec::new(), shut };
let mut dock = |ui: &mut egui::Ui| {
craft_ui::docking::DockArea::new(area).show_customized(
ui,
&drawn,
&style,
|id| {
crate::state::all_panels()
.find(|p| p.0 == id)
.map(|p| tl!(p.1).to_string())
.or_else(|| library_label(id))
.unwrap_or_else(|| id.clone())
},
|_| craft_ui::docking::Permissions::default(),
|id| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, if short.contains(id) { 0.0 } else { 80.0 }), ..Default::default() },
&mut content,
)
};
let output = match only_tabs {
Some(height) => {
let size = egui::vec2(ui.available_width(), height.min(ui.available_height()));
ui.allocate_ui(size, |ui| {
ui.set_max_height(size.y);
tree = ui.available_rect_before_wrap();
dock(ui)
})
.inner
}
None => dock(ui),
};
// A double-click on a tab of the dock's tree collapses its group to its tabs, or expands it.
if let Some(root) = &drawn.root {
let tabs = Layout { root: Some(root.clone()), floating: Vec::new() };
if let Some(panel) =
tabs.panels().into_iter().find(|p| ui.ctx().read_response(area.with(("tab", *p))).is_some_and(|r| r.double_clicked()))
{
content.commands.push(("window.panel.collapse", json!({"panel": panel})));
}
}
let groups = if collapsed { Vec::new() } else { root_groups(ui.ctx(), &display, tree, style.tab_height) };
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::root_groups_id(), groups));
for (id, params) in content.commands {
if let Err(error) = app.run(id, params) {
app.ui.status = error;
@@ -818,6 +1103,16 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
{
crate::floating::preview(ui.ctx(), target, rect);
}
// A tab dragged over the icon column becomes an icon there: light the column up (the shared
// dock shows the other drops).
if ui.input(|input| input.pointer.primary_down())
&& let Some(pos) = ui.input(|input| input.pointer.interact_pos())
&& let Some(panel) = dragged_tab(ui.ctx(), &layout).and_then(|panel| normalize(&panel))
&& let Some((target @ crate::floating::Drop::Icons, rect)) =
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
crate::floating::preview(ui.ctx(), target, rect);
}
if let Some(error) = output.error {
app.ui.status = error.to_string();
}
@@ -831,7 +1126,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
_ => return None,
};
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
.filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock))
.filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock | crate::floating::Drop::Root(..)))
.map(|(target, _)| (panel, target))
})
});
@@ -850,6 +1145,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin")));
}
for action in output.actions {
if matches!(&action, Action::ResizeSplit { path, .. } if fixed.contains(path)) {
continue;
}
if tab_drop
.is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel))
{
@@ -864,13 +1162,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true))
});
if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) {
let (command, params) = match target {
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})),
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})),
crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})),
crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})),
crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})),
};
let (command, params) = drop_command(panel, target, whole_group);
// Shared and native windows have already chosen their render ownership for this
// frame. Switch representations only after both renderers finish, so a panel's
// widgets never appear in two layers in the same frame.
@@ -894,6 +1186,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
true
}
/// The panel whose tab (of the shared dock, or of the legacy dock or icon column on the drag that
/// began the shared layout) is being dragged.
fn dragged_tab(ctx: &egui::Context, layout: &Layout<String>) -> Option<String> {
let area = egui::Id::new("vectorcraft-panel-docking");
if let Some(drag) = ctx.data(|data| data.get_temp::<LegacyDrag>(area.with("legacy-origin"))) {
return Some(drag.panel);
}
let dragged = ctx.dragged_id()?;
layout.panels().into_iter().find(|panel| area.with(("tab", *panel)) == dragged).cloned()
}
fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let column = egui::Panel::right("shared-panel-icons")
@@ -904,21 +1207,41 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
crate::theme::card(ui, &t, egui::Margin { left: 3, right: 3, top: 6, bottom: 6 }, |ui| {
egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
for &(id, label, icon) in crate::state::ICON_PANEL_GROUPS
// The tabbed group's panels dropped on the column head it, then the icon panels,
// then the other panels dropped on it (those the column has no place for).
let tabs: Vec<_> = crate::state::DockTab::ALL
.into_iter()
.map(crate::state::DockTab::info)
.filter(|info| app.ui.docking_icons.contains(info.0))
.collect();
let grouped = |id: &str| crate::state::ICON_PANEL_GROUPS.iter().any(|group| group.contains(&id));
let dropped: Vec<_> = crate::state::ICON_PANELS
.iter()
.flat_map(|group| group.iter())
.filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id))
.filter(|entry| !grouped(entry.0) && app.ui.docking_icons.contains(entry.0))
.copied()
.collect();
for (id, label, icon) in tabs
.into_iter()
.chain(
crate::state::ICON_PANEL_GROUPS
.iter()
.flat_map(|group| group.iter())
.filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id).copied()),
)
.chain(dropped)
{
if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) {
continue;
}
let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0));
let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag());
let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id));
let open = app.ui.open_panel.as_deref() == Some(id);
let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), open);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label)));
if response.clicked() {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) };
}
legacy_tab(app, ui, id, &response);
legacy_tab(app, ui, id, &response, false);
}
});
})
@@ -937,11 +1260,21 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
});
}
pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
legacy_tab_in_strip(app, ui, panel, response, None);
/// A tab of the dock's tabbed group (`closable`) or an icon of the icon column: its context menu
/// floats, groups or (a tab) closes its panel, and a drag moves it in the shared layout.
pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response, closable: bool) {
legacy_tab_in_strip(app, ui, panel, response, closable, None);
}
pub(crate) fn legacy_tab_in_strip(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response, strip: Option<egui::Rect>) {
/// [`legacy_tab`] for a tab of the tab strip `strip`, where a drag reorders the tabs.
pub(crate) fn legacy_tab_in_strip(
app: &mut VectorcraftApp,
ui: &mut egui::Ui,
panel: &str,
response: &egui::Response,
closable: bool,
strip: Option<egui::Rect>,
) {
let Some(panel) = normalize(panel) else { return };
response.context_menu(|ui| {
if ui.button(tl!("Float Panel")).clicked() {
@@ -964,6 +1297,12 @@ pub(crate) fn legacy_tab_in_strip(app: &mut VectorcraftApp, ui: &mut egui::Ui, p
}
}
});
if closable && ui.button(tl!("Close Panel")).clicked() {
if let Err(error) = app.run("window.panel.close", json!({"panel": panel})) {
app.ui.status = error;
}
ui.close();
}
});
let inside_strip = strip.is_some_and(|strip| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| strip.expand(4.0).contains(p)));
let area = egui::Id::new("vectorcraft-panel-docking");
@@ -983,10 +1322,12 @@ pub(crate) fn legacy_tab_in_strip(app: &mut VectorcraftApp, ui: &mut egui::Ui, p
data.insert_temp(
egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"),
LegacyDrag {
panel: id.to_string(),
layout: app.ui.docking.clone(),
open_panel: app.ui.open_panel.clone(),
floating: app.ui.floating_panels.clone(),
icons: app.ui.docking_icons.clone(),
collapsed: app.ui.docking_collapsed.clone(),
},
)
});
+437
View File
@@ -693,3 +693,440 @@ fn library_panels_float_with_their_names_and_a_persistent_menu_item() {
let persistent: Vec<&str> = h.state().ui.library_panels.iter().filter(|p| p.persistent).map(|p| p.id.as_str()).collect();
assert_eq!(persistent, ["pastels"]);
}
/// The dock groups the last frame registered as drop targets.
fn root_groups(h: &Harness<'static, VectorcraftApp>) -> Vec<crate::floating::RootGroup> {
h.ctx.data(|d| d.get_temp::<Vec<crate::floating::RootGroup>>(crate::floating::root_groups_id())).unwrap_or_default()
}
/// The dock's group holding `panel`, as panel ids.
fn root_group_of(app: &VectorcraftApp, panel: &str) -> Vec<String> {
let layout = app.ui.docking.as_ref().unwrap();
assert!(Layout { root: layout.root.clone(), floating: Vec::new() }.contains(&panel.to_string()), "{panel} is in the dock: {layout:?}");
group_members(layout, panel)
}
/// A legacy (native) floating group holding `panel`, as the Window menu or an icon tear leaves it
/// before the shared layout takes over, and a point on its title bar.
fn native_float(h: &mut Harness<'static, VectorcraftApp>, panel: &'static str) -> Pos2 {
crate::floating::float(&mut h.state_mut().ui, &[panel], panel, egui::pos2(300.0, 300.0));
h.run_steps(3);
assert!(h.state().ui.docking.is_none(), "still the legacy dock");
let r = h.ctx.memory(|m| m.area_rect(crate::floating::area_id(panel))).unwrap();
egui::pos2(r.left() + 30.0, r.top() + 1.0 + crate::floating::TITLE / 2.0)
}
/// A floating group dropped on the dock's tab strip joins the tabbed group as tabs.
#[test]
fn a_floating_group_dropped_on_the_dock_tabs_joins_the_tabbed_group() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let title = native_float(&mut h, "pathfinder");
let groups = root_groups(&h);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].panels, ["properties", "layers", "libraries"]);
let onto = groups[0].strip.right_center() - vec2(60.0, 0.0);
drag(&mut h, title, onto);
assert!(h.state().ui.floating_panels.is_empty(), "{:?}", h.state().ui.floating_panels);
assert_eq!(root_group_of(h.state(), "pathfinder"), ["properties", "layers", "libraries", "pathfinder"]);
assert!(!h.state().ui.docking_icons.contains("pathfinder"), "its home is the tabbed group now");
assert!(valid(h.state().ui.docking.as_ref().unwrap()));
}
/// A floating group dropped on a dock group's lower half becomes a new group below it.
#[test]
fn a_floating_group_dropped_on_the_lower_half_of_the_dock_splits_it() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let title = native_float(&mut h, "pathfinder");
let group = root_groups(&h)[0].rect;
drag(&mut h, title, egui::pos2(group.center().x, group.bottom() - 80.0));
assert!(h.state().ui.floating_panels.is_empty());
let layout = h.state().ui.docking.clone().unwrap();
let Some(Node::Split { axis: craft_ui::layout::SplitAxis::Vertical, second, .. }) = &layout.root else {
panic!("a top/bottom split: {layout:?}")
};
assert_eq!(**second, Node::Tabs { panels: vec!["pathfinder".into()], active: 0 });
assert_eq!(root_group_of(h.state(), "layers"), ["properties", "layers", "libraries"]);
// The two groups are drawn one above the other, and another group dropped on the upper half of
// the lower one goes between them.
h.run_steps(3);
let groups = root_groups(&h);
assert_eq!(groups.len(), 2, "{groups:?}");
assert!(groups[0].rect.bottom() <= groups[1].rect.top() + 1.0, "{groups:?}");
assert_eq!(groups[1].panels, ["pathfinder"]);
let lower = groups[1].rect;
h.state_mut().run("window.panel.float", json!({"panel":"align","x":300,"y":300})).unwrap();
h.run_steps(3);
let title =
h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"align".to_string())))).unwrap().left_top()
+ vec2(6.0, 16.0);
drag(&mut h, title, egui::pos2(lower.center().x, lower.top() + 50.0));
assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty());
h.run_steps(3);
let panels: Vec<_> = root_groups(&h).into_iter().map(|g| g.panels).collect();
assert_eq!(panels, [vec!["properties", "layers", "libraries"], vec!["align"], vec!["pathfinder"]]);
}
/// A floating group dropped on the icon column becomes icons there.
#[test]
fn a_floating_group_dropped_on_the_icon_column_becomes_icons() {
let mut h = harness(app(), vec2(1280.0, 1400.0));
h.state_mut().run("window.panel.float", json!({"panel":"layers","x":300,"y":300})).unwrap();
h.run_steps(3);
let title =
h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"layers".to_string())))).unwrap().left_top()
+ vec2(6.0, 16.0);
let column = h.ctx.data(|d| d.get_temp::<egui::Rect>(crate::floating::icons_rect_id())).unwrap();
drag(&mut h, title, column.center());
let ui = &h.state().ui;
assert!(ui.docking_icons.contains("layers"));
assert!(!ui.docking.as_ref().unwrap().contains(&"layers".to_string()));
assert_eq!(ui.open_panel.as_deref(), Some("layers"), "it pops out of its new icon");
}
/// A dock tab dragged onto the icon column becomes an icon whose flyout shows the panel.
#[test]
fn a_dock_tab_dragged_onto_the_icon_column_becomes_an_icon_and_its_flyout_shows() {
let mut h = harness(app(), vec2(1280.0, 1400.0));
let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
let column = h.ctx.data(|d| d.get_temp::<egui::Rect>(crate::floating::icons_rect_id())).unwrap();
drag(&mut h, tab, column.center());
let ui = &h.state().ui;
assert!(ui.docking_icons.contains("layers"), "{:?}", ui.docking_icons);
assert_eq!(root_group_of(h.state(), "properties"), ["properties", "libraries"]);
// Its icon heads the column; a click puts the popped-out panel away and another shows it again.
h.state_mut().ui.open_panel = None;
h.run_steps(3);
let column = h.ctx.data(|d| d.get_temp::<egui::Rect>(crate::floating::icons_rect_id())).unwrap();
let icon = h.query_all_by_label("Layers").map(|node| node.rect()).find(|r| column.contains_rect(*r)).expect("a Layers icon in the column");
h.event(Event::PointerMoved(icon.center()));
h.event(Event::PointerButton { pos: icon.center(), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: icon.center(), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_eq!(h.state().ui.open_panel.as_deref(), Some("layers"));
// The preferences keep it in the column.
let saved = serde_json::to_value(&h.state().ui).unwrap();
let restored: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert!(restored.sanitized().docking_icons.contains("layers"));
// Dragged from its icon back onto the tabbed group's tabs, it is a tab again.
let strip = root_groups(&h)[0].strip;
drag(&mut h, icon.center(), strip.right_center() - vec2(30.0, 0.0));
assert_eq!(root_group_of(h.state(), "layers"), ["properties", "libraries", "layers"]);
}
/// A dock tab's context menu closes its panel, and the Window menu brings it back.
#[test]
fn a_dock_tab_context_menu_closes_its_panel_and_the_window_menu_brings_it_back() {
let mut h = harness(app(), vec2(1280.0, 800.0));
assert!(h.state().ui.docking.is_none());
let tab = h.query_all_by_label("Libraries").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(tab));
h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
h.get_by_label("Close Panel").click();
h.run_steps(3);
let layout = h.state().ui.docking.clone().expect("closing starts the shared layout");
assert!(!layout.contains(&"libraries".to_string()));
assert_eq!(root_group_of(h.state(), "layers"), ["properties", "layers"]);
// In the shared dock the tabs' context menu closes them too.
let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
h.event(Event::PointerMoved(tab));
h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
h.get_by_label("Close Panel").click();
h.run_steps(3);
assert_eq!(root_group_of(h.state(), "properties"), ["properties"]);
h.state_mut().run("window.panel", json!({"panel":"libraries"})).unwrap();
h.state_mut().run("window.panel", json!({"panel":"layers"})).unwrap();
let mut panels = root_group_of(h.state(), "properties");
panels.sort();
assert_eq!(panels, ["layers", "libraries", "properties"]);
}
/// `window.panel.dock {icon}` and `{anchor, zone}` check their parameters and survive a workspace switch.
#[test]
fn icon_docking_commands_validate_and_round_trip_through_workspaces() {
let mut app = app();
for params in [json!({"panel":"layers","icon":3}), json!({"panel":"layers","icon":true,"anchor":"properties"})] {
assert!(app.run("window.panel.dock", params).is_err());
assert!(app.ui.docking.is_none());
}
assert!(app.run("window.panel.move", json!({"panel":"layers","anchor":"properties","icon":true})).is_err());
let r = app.run("window.panel.dock", json!({"panel":"layers","icon":true})).unwrap();
assert!(r.get("layout").is_some() && r.get("column").is_none(), "the shared layout's result: {r}");
assert!(app.ui.docking_icons.contains("layers"));
assert!(!app.ui.docking.as_ref().unwrap().contains(&"layers".to_string()));
let r = app.run("window.panel.dock", json!({"panel":"pathfinder","anchor":"properties","zone":"bottom"})).unwrap();
assert!(r.get("layout").is_some() && r.get("column").is_none(), "the shared layout's result: {r}");
assert!(matches!(&app.ui.docking.as_ref().unwrap().root, Some(Node::Split { axis: craft_ui::layout::SplitAxis::Vertical, .. })));
app.run("window.workspace.new", json!({"name":"Icons"})).unwrap();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
assert!(!app.ui.docking_icons.contains("layers"));
app.run("window.workspace", json!({"name":"Icons"})).unwrap();
assert!(app.ui.docking_icons.contains("layers"));
assert_eq!(root_group_of(&app, "pathfinder"), ["pathfinder"]);
// From the Window menu it pops out of its icon.
app.run("window.panel", json!({"panel":"layers"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
}
/// `column` goes with neither `anchor` nor `icon: true`, so it is never silently dropped;
/// `icon: false` is the same as no `icon`, so a dock column still takes the panel.
#[test]
fn a_dock_column_is_refused_with_an_anchor_or_icon_and_icon_false_is_no_icon() {
let mut app = app();
for params in [json!({"panel":"color","column":1,"anchor":"properties"}), json!({"panel":"color","column":true,"icon":true})] {
assert!(app.run("window.panel.dock", params.clone()).is_err(), "{params}");
assert!(app.ui.docking.is_none() && app.ui.floating_panels.is_empty(), "{params} changed nothing");
}
let r = app.run("window.panel.dock", json!({"panel":"color","column":true,"icon":false})).unwrap();
assert_eq!((r["column"].clone(), r["floating"].clone()), (json!(1), json!(false)));
}
/// A group dropped on the icon column leaves what the column can't show (a library panel) in the
/// dock instead of losing it.
#[test]
fn a_library_panel_dropped_on_the_icon_column_with_its_group_stays_in_the_dock() {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap();
let library = "library:swatches:pastels".to_string();
app.run("window.panel.float", json!({"panel": library, "x": 300, "y": 100})).unwrap();
app.run("window.panel.move", json!({"panel":"color", "anchor": library})).unwrap();
assert_eq!(group_members(app.ui.docking.as_ref().unwrap(), &library).len(), 2, "one floating group");
app.run("window.panel.dock", json!({"panel": library, "group": true, "icon": true})).unwrap();
assert!(app.ui.docking_icons.contains("color"));
assert!(!app.ui.docking_icons.contains(&library));
assert!(Layout { root: app.ui.docking.as_ref().unwrap().root.clone(), floating: Vec::new() }.contains(&library), "in the dock");
}
/// A panel outside the icon groups still gets an icon once dropped on the column.
#[test]
fn a_panel_the_icon_column_has_no_place_for_still_shows_there_once_dropped_on_it() {
let mut h = harness(app(), vec2(1280.0, 1600.0));
assert!(!crate::state::ICON_PANEL_GROUPS.iter().any(|group| group.contains(&"charStyles")));
h.state_mut().run("window.panel.float", json!({"panel":"charStyles","x":300,"y":300})).unwrap();
h.run_steps(3);
let title =
h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"charStyles".to_string())))).unwrap().left_top()
+ vec2(6.0, 16.0);
let column = h.ctx.data(|d| d.get_temp::<egui::Rect>(crate::floating::icons_rect_id())).unwrap();
drag(&mut h, title, column.center());
assert!(h.state().ui.docking_icons.contains("charStyles"));
h.state_mut().ui.open_panel = None;
h.run_steps(3);
let column = h.ctx.data(|d| d.get_temp::<egui::Rect>(crate::floating::icons_rect_id())).unwrap();
assert!(h.query_all_by_label("Character Styles").any(|node| column.contains_rect(node.rect())), "its icon is in the column");
}
/// Double-clicks at `at`, then waits out the double-click time.
fn double_click(h: &mut Harness<'static, VectorcraftApp>, at: Pos2) {
h.event(Event::PointerMoved(at));
h.run_steps(1);
for _ in 0..2 {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(1);
}
// Past the double- and triple-click times, so the next press is a click of its own.
h.run_steps(45);
}
/// The Layers panel's body is drawn (its search field).
fn layers_drawn(h: &Harness<'static, VectorcraftApp>) -> bool {
h.ctx.read_response(egui::Id::new("layers-search").with("edit")).is_some()
}
/// Clicks at `at`.
fn click(h: &mut Harness<'static, VectorcraftApp>, at: Pos2) {
h.event(Event::PointerMoved(at));
h.run_steps(1);
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// The middle of `panel`'s tab in the shared dock.
/// Two stacked groups both collapsed are both as short as their tabs: the lower one doesn't take
/// the rest of the dock as a blank pane.
#[test]
fn two_stacked_groups_both_collapsed_are_both_as_short_as_their_tabs() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.collapse", json!({"panel":"swatches", "collapsed": true})).unwrap();
app.run("window.panel.collapse", json!({"panel":"layers", "collapsed": true})).unwrap();
let h = harness(app, vec2(1280.0, 800.0));
let groups = root_groups(&h);
assert_eq!(groups.len(), 2, "{groups:?}");
for group in &groups {
assert!(group.rect.height() < 40.0, "{groups:?}");
}
}
fn tab_center(h: &Harness<'static, VectorcraftApp>, panel: &str) -> Pos2 {
h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect.center()
}
/// A double-click on a tab of the shared dock collapses its group to its tabs; another tab expands it.
#[test]
fn a_double_click_on_a_dock_tab_collapses_its_group_to_its_tabs_and_another_expands_it() {
let mut app = app();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
let saved = app.ui.docking.clone();
let mut h = harness(app, vec2(1280.0, 800.0));
let groups = root_groups(&h);
assert_eq!(groups.len(), 2, "{groups:?}");
let (top, lower) = (groups[0].rect, groups[1].rect);
assert!(top.height() > 100.0, "{top:?}");
// The upper group shrinks to its tabs and the lower one takes the room; the saved layout keeps
// its sizes.
let at = tab_center(&h, "swatches");
double_click(&mut h, at);
assert!(h.state().ui.docking_collapsed.contains("swatches"));
let groups = root_groups(&h);
assert!(groups[0].rect.height() < 40.0, "{groups:?}");
assert!(groups[1].rect.top() < lower.top() - 50.0, "{groups:?}");
assert_eq!(h.state().ui.docking, saved);
// Again, and it is as tall as before.
let at = tab_center(&h, "swatches");
double_click(&mut h, at);
assert!(h.state().ui.docking_collapsed.is_empty());
let groups = root_groups(&h);
assert!((groups[0].rect.height() - top.height()).abs() < 2.0, "{groups:?} {top:?}");
// The lower group, tabbed: its body goes, and a click on another of its tabs brings it back
// showing that panel.
let at = tab_center(&h, "layers");
double_click(&mut h, at);
assert!(h.state().ui.docking_collapsed.contains("layers"));
assert!(!layers_drawn(&h), "no Layers body");
let at = tab_center(&h, "properties");
click(&mut h, at);
assert!(h.state().ui.docking_collapsed.is_empty());
assert_eq!(root_group_of(h.state(), "properties"), ["properties", "layers", "libraries"]);
assert!(valid(h.state().ui.docking.as_ref().unwrap()));
}
/// A double-click on a tab of the legacy dock collapses the tabbed group to its tabs.
#[test]
fn a_double_click_on_a_legacy_dock_tab_collapses_the_tabbed_group() {
let mut app = app();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
click(&mut h, tab);
h.run_steps(45);
assert!(layers_drawn(&h), "Layers shows");
double_click(&mut h, tab);
assert!(h.state().ui.docking.is_none(), "still the legacy dock");
assert!(h.state().ui.docking_collapsed.contains("layers"));
assert!(!layers_drawn(&h), "no Layers body");
double_click(&mut h, tab);
assert!(h.state().ui.docking_collapsed.is_empty());
assert!(layers_drawn(&h), "Layers shows again");
}
/// `window.panel.collapse` checks its parameters, and a collapsed group survives a reload.
#[test]
fn the_collapse_command_validates_and_survives_a_reload() {
let mut app = app();
assert_eq!(app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(), json!({"panel":"layers", "collapsed":true}));
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers, "it shows the panel");
let saved = serde_json::to_value(&app.ui).unwrap();
let restored = serde_json::from_value::<crate::state::UiState>(saved).unwrap().sanitized();
assert!(restored.docking_collapsed.contains("layers"));
assert_eq!(app.run("window.panel.collapse", json!({"panel":"layers", "collapsed":false})).unwrap()["collapsed"], false);
for params in [json!({"panel":"nope"}), json!({"panel":"layers", "collapsed":"yes"}), json!({"panel":"swatches"})] {
assert!(app.run("window.panel.collapse", params.clone()).is_err(), "{params}");
}
app.run("window.panel.float", json!({"panel":"layers", "x":300, "y":300})).unwrap();
assert!(app.run("window.panel.collapse", json!({"panel":"layers"})).is_err(), "floating groups collapse with float");
assert!(app.ui.docking_collapsed.is_empty());
}
/// A panel moved out of a collapsed group doesn't take the collapse along.
#[test]
fn a_collapsed_groups_panel_moved_elsewhere_takes_no_collapse_along() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap();
// Layers, shown by the collapsed group, goes to the Swatches group and is shown there.
app.run("window.panel.move", json!({"panel":"layers", "anchor":"swatches"})).unwrap();
app.run("window.panel.activate", json!({"panel":"layers"})).unwrap();
crate::panel_docking::prune_collapsed(&mut app.ui);
assert!(app.ui.docking_collapsed.is_empty(), "neither group is collapsed");
// The same through a serialized action (a drag).
app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap();
app.run("window.panel.layout", json!({"action": {"Move": {"panel":"layers", "anchor":"properties", "placement": {"Tab": {"before": null}}}}}))
.unwrap();
assert!(app.ui.docking_collapsed.is_empty());
}
/// `window.panel.collapse` refuses a panel that is in an accordion, not a tab group.
#[test]
fn the_collapse_command_rejects_an_accordion_panel() {
let mut app = app();
app.ui.docking = Some(Layout {
root: Some(Node::Stack {
entries: vec![
craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: None },
craft_ui::docking::StackEntry { panel: "swatches".into(), open: true, height: None },
],
}),
floating: Vec::new(),
});
assert!(valid(app.ui.docking.as_ref().unwrap()));
assert!(app.run("window.panel.collapse", json!({"panel":"layers"})).is_err());
assert!(app.ui.docking_collapsed.is_empty());
}
/// Escape on a collapsed group's drag puts it back collapsed.
#[test]
fn escape_on_a_collapsed_groups_drag_leaves_it_collapsed() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
// An icon panel's flyout is open too.
h.state_mut().ui.open_panel = Some("color".into());
h.run_steps(2);
let groups = root_groups(&h);
let lower = groups.iter().find(|g| g.panels.contains(&"layers")).unwrap().strip;
// The grip right of the lower group's tabs, dragged out of the dock, then Escape.
let from = lower.right_center() - vec2(50.0, 0.0);
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for step in 1..=6 {
h.event(Event::PointerMoved(from - vec2(80.0 * step as f32, 0.0)));
h.run_steps(1);
}
assert!(h.state().ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels.contains(&"layers".into())), "the group floats mid-drag");
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty(), "back in the dock");
assert!(h.state().ui.docking_collapsed.contains("layers"), "and still collapsed");
assert_eq!(h.state().ui.open_panel.as_deref(), Some("color"), "the flyout is back");
}
/// A group beside another (a left/right split) is refused: it can't be shorter than its neighbour.
#[test]
fn a_group_beside_another_does_not_collapse() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"left"})).unwrap();
let before = app.ui.docking.clone();
assert!(app.run("window.panel.collapse", json!({"panel":"swatches"})).is_err());
assert!(app.run("window.panel.collapse", json!({"panel":"properties"})).is_err());
assert_eq!(app.ui.docking, before, "refused without a change");
assert!(app.ui.docking_collapsed.is_empty());
// One left collapsed by an earlier layout (a hand-edited file) is expanded.
app.ui.docking_collapsed.insert("swatches".into());
crate::panel_docking::prune_collapsed(&mut app.ui);
assert!(app.ui.docking_collapsed.is_empty());
}
+30
View File
@@ -94,6 +94,36 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
});
let selected = info["target"] == "blend";
// Position easing needs a spine to run along: a replaced one, or Align to Path.
let spined = info["spine"]["explicit"] == true || info["orientation"] == "path";
ui.add_enabled_ui(selected && spined, |ui| {
let modes = ["easeIn", "easeOut", "easeInOut", "easeOutIn"];
let labels = [tl!("Ease In"), tl!("Ease Out"), tl!("Ease In Out"), tl!("Ease Out In")];
let mode = info["easingType"].as_str().unwrap_or(modes[0]);
let index = modes.iter().position(|m| *m == mode).unwrap_or(0);
widgets::label_row(ui, tl!("Type:"), LABEL_W, |ui| {
if let Some(i) = widgets::dropdown(ui, "blend-panel-easing", labels[index], &labels, ui.available_width().max(FIELD_W))
&& let Some(mode) = modes.get(i)
{
change = Some(json!({"easingType": mode}));
}
});
widgets::label_row(ui, tl!("Ramp:"), LABEL_W, |ui| {
let mut ramp = info["ramp"].as_f64().unwrap_or(0.0);
if ui.add(egui::Slider::new(&mut ramp, 0.0..=100.0).suffix("%")).changed() {
change = Some(json!({"ramp": ramp}));
}
});
});
ui.add_enabled_ui(selected, |ui| {
widgets::label_row(ui, tl!("Shift:"), LABEL_W, |ui| {
let mut shift = info["colorShift"].as_f64().unwrap_or(0.0);
if ui.add(egui::Slider::new(&mut shift, -100.0..=100.0).suffix("%")).changed() {
change = Some(json!({"colorShift": shift}));
}
});
});
if let Some(p) = change {
app.run("object.blend.options", p).ok();
}
+63 -8
View File
@@ -712,6 +712,24 @@ fn style_toggle(ui: &mut Ui, g: Glyph, tip: &str, on: bool) -> bool {
resp.clicked()
}
/// The Character Direction submenu: Default, Left to Right and Right to Left set the characters the
/// panel acts on ([`char_cmd`]) as the bidi algorithm sets them, or left to right or right to left
/// inside their paragraph. The direction those characters share is checked, and none is checked
/// when they differ.
fn char_direction_items(app: &mut VectorcraftApp, ui: &mut Ui) {
use vectorcraft_doc::ParaDirection;
let direction = shared(app, |s| s.char_direction);
for (label, d, key) in [
(tl!("Default"), None, "default"),
(tl!("Left to Right"), Some(ParaDirection::LeftToRight), "leftToRight"),
(tl!("Right to Left"), Some(ParaDirection::RightToLeft), "rightToLeft"),
] {
if menu_item(ui, label, true, direction == Some(d)) {
format(app, json!({"charDirection": key}));
}
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let st = text_style(app);
let has = st.is_some();
@@ -795,6 +813,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
});
ui.add_enabled_ui(has, |ui| {
ui.menu_button(format!(" {}", tl!("Character Direction")), |ui| char_direction_items(app, ui));
});
ui.separator();
}
// No Break: the selected characters stay on one line.
@@ -847,8 +868,9 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool {
/// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and
/// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps
/// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline
/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off and the Default digits, whether
/// or not Show East Asian Options and Show Indic Options are on.
/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off, the Default character
/// direction and the Default digits, whether or not Show East Asian Options and Show Indic Options
/// are on.
fn reset_panel(app: &mut VectorcraftApp) {
let d = CharStyle::default();
style(app, json!({"tracking": 0, "leading": "auto"}));
@@ -857,7 +879,8 @@ fn reset_panel(app: &mut VectorcraftApp) {
json!({
"kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false,
"allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align,
"proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "digits": d.digits
"proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "charDirection": "default",
"digits": d.digits
}),
);
}
@@ -1292,6 +1315,36 @@ mod tests {
assert!(shown(&mut app));
}
/// Character Direction is in the panel menu with the Middle Eastern (Indic) options, beside
/// Digits. Its submenu checks the direction the characters the panel acts on share: the
/// selected type's, or the range the Type tool has selected. It checks no item when they differ.
#[test]
fn character_direction_checks_the_direction_of_the_selection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Direction");
assert!(!shown(&mut app));
app.session.prefs.show_indic_options = true;
assert!(shown(&mut app));
let checked = |app: &mut VectorcraftApp| {
let text = crate::tests_labels::painted_text(app, char_direction_items);
text.lines().filter_map(|l| l.strip_prefix("✓ ")).map(str::to_string).collect::<Vec<_>>()
};
assert_eq!(checked(&mut app), ["Default"]);
app.run("text.setRangeStyle", json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap();
// The object's characters differ: nothing is checked.
assert!(checked(&mut app).is_empty());
// The Type tool's selection: the date, then the Hebrew word.
app.select_tool("type");
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
app.session.set_tool_option("select", &json!({"start": 9, "end": 19}));
assert_eq!(checked(&mut app), ["Left to Right"]);
app.session.set_tool_option("select", &json!({"start": 0, "end": 8}));
assert_eq!(checked(&mut app), ["Default"]);
}
/// No Break is in the panel menu, and choosing it marks the selected characters.
#[test]
fn no_break_menu_item_toggles_the_selected_characters() {
@@ -1321,8 +1374,8 @@ mod tests {
/// Reset Panel sets every character attribute the panel shows, except the font family, style
/// and size, back to its default (Small Caps, the position, Character Alignment, Proportional
/// Metrics, Tate-chu-yoko, No Break and Digits included), in the selected type and in the text
/// the Type tool has selected while it edits.
/// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected
/// type and in the text the Type tool has selected while it edits.
#[test]
fn reset_panel_resets_every_attribute_but_the_font() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -1350,6 +1403,7 @@ mod tests {
s.proportional_metrics != d.proportional_metrics,
s.tate_chu_yoko != d.tate_chu_yoko,
s.no_break != d.no_break,
s.char_direction != d.char_direction,
s.digits != d.digits,
]
};
@@ -1359,16 +1413,17 @@ mod tests {
json!({
"id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15,
"underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript",
"charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, "digits": "hindi"
"charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true,
"charDirection": "rightToLeft", "digits": "hindi"
}),
)
.unwrap();
};
let plain = runs(&app)[0].1.clone();
assert_eq!(differs(&plain, &CharStyle::default()), [false; 17]);
assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]);
custom(&mut app);
let styled = runs(&app)[0].1.clone();
assert_eq!(differs(&styled, &CharStyle::default()), [true; 17]);
assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]);
app.run("select.set", json!({"ids": [id]})).unwrap();
reset_panel(&mut app);
assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]);
+34
View File
@@ -225,3 +225,37 @@ fn each_button_runs_its_blend_command() {
let objects = top(&app);
assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}");
}
#[test]
fn panel_easing_type_ramp_and_shift_edit_the_blend_and_undo() {
// Type and Ramp need a spine: Align to Path gives the blend one.
let mut app = app();
let id = NodeId(app.run("object.blend.make", json!({"smooth": true, "orientation": "path"})).unwrap()["id"].as_u64().unwrap());
let mut f = Frames::new();
for label in ["Type:", "Ease In", "Ramp:", "Shift:"] {
f.text(&mut app, label);
}
f.click_text(&mut app, "Ease In");
f.click_text(&mut app, "Ease Out In");
assert_eq!(spec(&app, id).ease.kind, vectorcraft_doc::BlendEaseType::OutIn);
// Click each real slider: its command updates the engine, and Undo restores the old value.
for (index, key) in [(0, "ramp"), (1, "colorShift")] {
let out = f.run(&mut app, vec![]);
let nodes = &out.platform_output.accesskit_update.as_ref().unwrap().nodes;
let mut sliders: Vec<_> = nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Slider).collect();
// The tree is not in layout order: Ramp sits above Shift.
sliders.sort_by(|a, b| a.1.bounds().unwrap().y0.total_cmp(&b.1.bounds().unwrap().y0));
let slider = sliders[index];
let b = slider.1.bounds().unwrap();
let at = pos2((b.x0 + 0.3 * (b.x1 - b.x0)) as f32, ((b.y0 + b.y1) * 0.5) as f32);
let before = app.session.execute("object.blend.info", &json!({})).unwrap()[key].clone();
f.click(&mut app, at);
let after = app.session.execute("object.blend.info", &json!({})).unwrap()[key].clone();
assert_ne!(after, before, "{key}");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()[key], before);
}
app.run("ui.blendOptions", json!({})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
assert!(!text.contains("Ease In:") && !text.contains("Ramp:") && !text.contains("Color Ease"), "{text}");
}
+5 -1
View File
@@ -22,6 +22,9 @@ pub struct Job {
pub struct Done {
pub key: CacheKey,
/// The document rendered, and the options it was rendered with.
pub doc: Arc<Document>,
pub opts: RenderOptions,
pub img: Rendered,
pub ms: f64,
/// [`vectorcraft_render::FrameStats::moved`] of the frame.
@@ -53,7 +56,8 @@ impl Worker {
}
let t0 = crate::now_ms();
let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts);
if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() {
if done_tx.send(Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err()
{
break;
}
ctx.request_repaint();
+34 -1
View File
@@ -145,6 +145,12 @@ pub fn all_panels() -> impl Iterator<Item = (&'static str, &'static str)> {
tabs.chain(ICON_PANELS.iter().map(|&(id, label, _)| (id, label)))
}
/// Panel `id`'s id, English label and icon if it can live in the icon column: an icon panel, or
/// one of the tabbed group's panels dropped there.
pub fn rail_panel(id: &str) -> Option<(&'static str, &'static str, &'static str)> {
ICON_PANELS.iter().find(|entry| entry.0 == id).copied().or_else(|| DockTab::from_id(id).map(DockTab::info))
}
/// Panels that live as collapsed icons in the dock (Essentials Classic).
pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("color", "Color", "palette"),
@@ -363,6 +369,10 @@ pub struct UiState {
/// Panels whose saved return destination is the iconic rail.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub docking_icons: std::collections::BTreeSet<String>,
/// The panels shown by dock groups collapsed to their tabs (a double-click on a tab); a group
/// showing another of its panels is expanded.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub docking_collapsed: std::collections::BTreeSet<String>,
/// Invalidates in-flight gestures when a workspace is replaced.
#[serde(skip)]
pub docking_generation: u64,
@@ -428,6 +438,10 @@ pub struct UiState {
/// User-saved workspaces (New Workspace…).
#[serde(default)]
pub custom_workspaces: Vec<crate::workspaces::Workspace>,
/// Layout changes made in a workspace since it was saved (or last reset), by workspace name:
/// switching back to it brings them back; Reset <name> drops them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_changes: Vec<crate::workspaces::Workspace>,
/// File → Open Recent Files, most recent first.
#[serde(default)]
pub recent_files: Vec<String>,
@@ -770,7 +784,24 @@ impl UiState {
FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos);
crate::edit_toolbar::sanitize(&mut self.toolbar_edits);
self.docking = self.docking.filter(crate::panel_docking::valid);
self.docking_icons.retain(|id| ICON_PANELS.iter().any(|entry| entry.0 == id));
// The tabbed group's panels live in the icon column too once dropped there.
self.docking_icons.retain(|id| rail_panel(id).is_some());
self.docking_collapsed = std::mem::take(&mut self.docking_collapsed)
.into_iter()
.filter(|id| crate::menus::normalize_panel(id) == Some(id.as_str()))
.take(64)
.collect();
// Changes kept for workspaces that still exist, under their saved spelling and one per
// workspace whatever the case (a hand-edited file).
let mut seen = std::collections::BTreeSet::new();
let saved: Vec<String> =
self.custom_workspaces.iter().map(|w| w.name.clone()).chain(crate::workspaces::builtins().into_iter().map(|w| w.name)).collect();
self.workspace_changes.retain_mut(|w| {
let Some(name) = saved.iter().find(|n| n.eq_ignore_ascii_case(&w.name)) else { return false };
w.name = name.clone();
seen.insert(name.to_ascii_lowercase())
});
self.workspace_changes.truncate(64);
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
// the default back.
self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some());
@@ -803,6 +834,7 @@ impl Default for UiState {
docking: None,
docking_hidden: Default::default(),
docking_icons: Default::default(),
docking_collapsed: Default::default(),
docking_generation: 0,
dock_width: None,
dock_width_pending: false,
@@ -829,6 +861,7 @@ impl Default for UiState {
workspace: default_workspace(),
touch_workspace_restore: None,
custom_workspaces: vec![],
workspace_changes: vec![],
recent_files: vec![],
recent_fonts: vec![],
favorite_fonts: vec![],
+19
View File
@@ -161,6 +161,25 @@ fn the_file_type_picked_in_the_save_dialog_decides_the_format() {
}
}
/// The suggested name's extension typed in another case still gets the picked type's
/// (`Untitled-1.VECTORCRAFT` with .ai picked is saved as `Untitled-1.ai`), and the picked type's
/// own extension typed in another case stays as typed (`a.AI` with .ai picked).
#[test]
fn the_suggested_names_extension_in_another_case_gets_the_picked_types() {
for (cmd, answer, expected) in [
("file.save", &["/art/Untitled-1.VECTORCRAFT", "ai"][..], "/art/Untitled-1.ai"),
("file.save", &["/art/Untitled-1.VectorCraft", "svg"], "/art/Untitled-1.svg"),
("file.saveAsTemplate", &["/art/Untitled-1.VCTemplate", "ait"], "/art/Untitled-1.ait"),
] {
let (mut app, shown, written) = portal_app();
assert_eq!(saved_from(&mut app, &shown, &written, cmd, answer), expected, "{cmd} {answer:?}");
}
// A document saved as .ai suggests `a.ai`.
let (mut app, shown, written) = portal_app();
app.run("file.saveAs", json!({"path": "/art/a.ai", "options": {}})).unwrap();
assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/a.AI", "ai"]), "/art/a.AI");
}
/// A name typed with the extension of a format the save does not write has it replaced, with or
/// without a picked type: `card.ait` and `card.png` are saved as `card.vectorcraft`, not
/// `card.ait.vectorcraft`. An extension that names no format stays part of the name.
+183 -18
View File
@@ -1,6 +1,11 @@
//! Window → Workspace: saved panel/bar layouts (Essentials, Essentials Classic, Painting, …),
//! Reset, New Workspace…, Manage Workspaces…. A workspace is a snapshot of `UiState` layout
//! flags; UI brightness and everything else is untouched when switching.
//!
//! As in Illustrator, a workspace remembers the changes made in it: switching to another one keeps
//! the layout as it was left (`UiState::workspace_changes`, kept with the preferences), switching
//! back brings it back, and Reset <name> returns to the workspace as saved. New Workspace… with
//! the name of a user workspace saves the current layout as it.
use std::sync::atomic::Ordering;
@@ -39,6 +44,9 @@ pub struct Workspace {
/// Panels whose saved return destination is the iconic rail.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub docking_icons: std::collections::BTreeSet<String>,
/// The panels shown by dock groups collapsed to their tabs.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub docking_collapsed: std::collections::BTreeSet<String>,
/// Where the Tools panel floats (built-in workspaces: docked).
pub toolbar_pos: Option<[f32; 2]>,
/// Main dock column width in screen points (#1753).
@@ -175,6 +183,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
docking: ui.docking.clone(),
docking_hidden: ui.docking_hidden.clone(),
docking_icons: ui.docking_icons.clone(),
docking_collapsed: ui.docking_collapsed.clone(),
toolbar_pos: ui.toolbar_pos,
dock_width: ui.dock_width,
}
@@ -199,8 +208,8 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.docking_generation = ui.docking_generation.wrapping_add(1);
ui.docking = w.docking.clone().filter(crate::panel_docking::valid);
ui.docking_hidden = w.docking_hidden.clone();
ui.docking_icons =
w.docking_icons.iter().filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())).take(64).cloned().collect();
ui.docking_icons = w.docking_icons.iter().filter(|id| crate::state::rail_panel(id).is_some()).take(64).cloned().collect();
ui.docking_collapsed = w.docking_collapsed.iter().filter(|id| crate::menus::normalize_panel(id) == Some(id.as_str())).take(64).cloned().collect();
ui.toolbar_pos = w.toolbar_pos;
ui.dock_width = w.dock_width;
ui.dock_width_pending = true;
@@ -209,8 +218,37 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.workspace = w.name.clone();
}
/// A workspace by name (user workspaces shadow nothing: names are unique across both lists).
pub fn find(ui: &UiState, name: &str) -> Option<Workspace> {
/// Keep the layout of the current workspace as it is now, so switching back brings it back.
fn remember(ui: &mut UiState) {
// Kept under the saved workspace's spelling, which `find_saved` matches whatever the case.
let Some(saved) = find_saved(ui, &ui.workspace.clone()) else { return };
let w = capture(ui, &saved.name);
ui.workspace_changes.retain(|x| !x.name.eq_ignore_ascii_case(&saved.name));
ui.workspace_changes.push(w);
// Over the cap, the oldest go: the layout just kept is the newest.
let over = ui.workspace_changes.len().saturating_sub(64);
ui.workspace_changes.drain(..over);
}
/// Switch to workspace `name` as it was left (its changes since it was saved), else as saved.
pub fn switch(ui: &mut UiState, name: &str) -> Result<String, String> {
let saved = find_saved(ui, name).ok_or_else(|| format!("unknown workspace (one of {})", names(ui).join(", ")))?;
remember(ui);
let w = ui.workspace_changes.iter().find(|w| w.name.eq_ignore_ascii_case(&saved.name)).cloned().unwrap_or(saved);
apply(ui, &w);
Ok(w.name)
}
/// Reset the current workspace to the way it was saved, dropping its changes.
pub fn reset(ui: &mut UiState) -> String {
let w = find_saved(ui, &ui.workspace.clone()).unwrap_or_default();
ui.workspace_changes.retain(|x| !x.name.eq_ignore_ascii_case(&w.name));
apply(ui, &w);
w.name
}
/// A workspace by name as saved (user workspaces shadow nothing: names are unique across both lists).
pub fn find_saved(ui: &UiState, name: &str) -> Option<Workspace> {
ui.custom_workspaces
.iter()
.find(|w| w.name.eq_ignore_ascii_case(name))
@@ -218,6 +256,11 @@ pub fn find(ui: &UiState, name: &str) -> Option<Workspace> {
.or_else(|| builtins().into_iter().find(|w| w.name.eq_ignore_ascii_case(name)))
}
/// A workspace by name as saved.
pub fn find(ui: &UiState, name: &str) -> Option<Workspace> {
find_saved(ui, name)
}
/// Every workspace name: built-ins then user workspaces.
pub fn names(ui: &UiState) -> Vec<String> {
builtins().into_iter().map(|w| w.name).chain(ui.custom_workspaces.iter().map(|w| w.name.clone())).collect()
@@ -232,11 +275,15 @@ pub fn save_as(ui: &mut UiState, name: &str) -> Result<(), String> {
if is_builtin(name) {
return Err(format!("`{name}` is a built-in workspace; choose another name"));
}
// The changes go into the workspace saved, which starts with none; the one left keeps the
// changes it had (none of these).
// A user workspace of that name in another case is replaced, as `find_saved` would find it.
let w = capture(ui, name);
match ui.custom_workspaces.iter_mut().find(|x| x.name == name) {
match ui.custom_workspaces.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
Some(x) => *x = w,
None => ui.custom_workspaces.push(w),
}
ui.workspace_changes.retain(|x| !x.name.eq_ignore_ascii_case(name));
ui.workspace = name.into();
Ok(())
}
@@ -247,8 +294,13 @@ pub fn delete(ui: &mut UiState, name: &str) -> Result<(), String> {
if ui.custom_workspaces.len() == before {
return Err(if is_builtin(name) { format!("`{name}` is built in and can't be deleted") } else { format!("no workspace `{name}`") });
}
ui.workspace_changes.retain(|w| w.name != name);
// The current workspace deleted: Essentials (as it was left) takes over, rather than its layout
// staying on screen under Essentials's name, to be remembered as Essentials's changes.
if ui.workspace == name {
ui.workspace = ESSENTIALS.into();
let essentials =
ui.workspace_changes.iter().find(|w| w.name == ESSENTIALS).cloned().or_else(|| find_saved(ui, ESSENTIALS)).unwrap_or_default();
apply(ui, &essentials);
}
Ok(())
}
@@ -260,6 +312,9 @@ pub fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> {
}
let w = ui.custom_workspaces.iter_mut().find(|w| w.name == from).ok_or_else(|| format!("no user workspace `{from}`"))?;
w.name = to.into();
for w in ui.workspace_changes.iter_mut().filter(|w| w.name == from) {
w.name = to.into();
}
if ui.workspace == from {
ui.workspace = to.into();
}
@@ -337,18 +392,8 @@ pub fn popup(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"window.workspace" => match s("name").and_then(|n| find(&app.ui, &n)) {
Some(w) => {
apply(&mut app.ui, &w);
Ok(json!({"workspace": w.name}))
}
None => Err(format!("unknown workspace (one of {})", names(&app.ui).join(", "))),
},
"window.workspace.reset" => {
let w = find(&app.ui, &app.ui.workspace.clone()).unwrap_or_default();
apply(&mut app.ui, &w);
Ok(json!({"workspace": w.name}))
}
"window.workspace" => switch(&mut app.ui, &s("name").unwrap_or_default()).map(|name| json!({"workspace": name})),
"window.workspace.reset" => Ok(json!({"workspace": reset(&mut app.ui)})),
"window.workspace.new" => match s("name") {
Some(n) => save_as(&mut app.ui, &n).map(|_| json!({"workspace": n})),
None => {
@@ -509,6 +554,126 @@ mod tests {
assert_eq!(back.workspace, "Mine");
}
/// Changes made in a workspace come back after switching away and back, and after a restart, until Reset.
#[test]
fn a_workspace_keeps_its_changes_across_switches_until_reset() {
let mut ui = UiState::default();
save_as(&mut ui, "Görkem").unwrap();
// Changes made in it after saving (a panel moved, the control bar shown)…
ui.control_bar = true;
ui.dock_tab = DockTab::Layers;
ui.docking_icons.insert("layers".into());
ui.docking_collapsed.insert("layers".into());
// …are still there after switching away and back.
switch(&mut ui, ESSENTIALS).unwrap();
assert!(!ui.control_bar && ui.docking_icons.is_empty() && ui.docking_collapsed.is_empty(), "Essentials comes as saved");
switch(&mut ui, "Görkem").unwrap();
assert!(ui.control_bar && ui.dock_tab == DockTab::Layers && ui.docking_icons.contains("layers"));
assert!(ui.docking_collapsed.contains("layers"), "its collapsed group too");
// Built-in workspaces remember theirs too.
switch(&mut ui, ESSENTIALS).unwrap();
ui.status_bar = false;
switch(&mut ui, "Görkem").unwrap();
switch(&mut ui, ESSENTIALS).unwrap();
assert!(!ui.status_bar);
// They survive a restart (the preferences' JSON round trip).
let mut ui: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();
ui = ui.sanitized();
switch(&mut ui, "Görkem").unwrap();
assert!(ui.control_bar);
// Reset Görkem: back to the way it was saved.
assert_eq!(reset(&mut ui), "Görkem");
assert!(!ui.control_bar && ui.docking_icons.is_empty());
switch(&mut ui, ESSENTIALS).unwrap();
switch(&mut ui, "Görkem").unwrap();
assert!(!ui.control_bar, "a reset workspace stays reset");
// Renamed, it keeps its changes; deleted, they go.
ui.control_bar = true;
switch(&mut ui, ESSENTIALS).unwrap();
rename(&mut ui, "Görkem", "G").unwrap();
switch(&mut ui, "G").unwrap();
assert!(ui.control_bar);
switch(&mut ui, ESSENTIALS).unwrap();
delete(&mut ui, "G").unwrap();
assert!(ui.workspace_changes.iter().all(|w| w.name != "G"));
}
/// Deleting the current workspace shows Essentials as it was left, not the deleted one's layout.
#[test]
fn deleting_the_current_workspace_switches_to_essentials_without_leaking_its_layout() {
let mut ui = UiState::default();
save_as(&mut ui, "Tmp").unwrap();
ui.control_bar = true;
delete(&mut ui, "Tmp").unwrap();
assert_eq!(ui.workspace, ESSENTIALS);
assert!(!ui.control_bar, "Essentials's layout is shown, not the deleted one's");
save_as(&mut ui, "Other").unwrap();
switch(&mut ui, ESSENTIALS).unwrap();
assert!(!ui.control_bar, "Essentials didn't remember the deleted workspace's layout");
}
/// With 64 layouts remembered, the oldest goes and the one just left is kept.
#[test]
fn a_full_list_of_remembered_layouts_drops_the_oldest_not_the_newest() {
let mut ui = UiState::default();
let names: Vec<String> = (0..70).map(|i| format!("W{i}")).collect();
for name in &names {
save_as(&mut ui, name).unwrap();
}
for name in &names {
switch(&mut ui, name).unwrap();
ui.control_bar = true;
}
switch(&mut ui, ESSENTIALS).unwrap();
assert_eq!(ui.workspace_changes.len(), 64);
assert!(ui.workspace_changes.iter().all(|w| w.name != "W0"), "the oldest went");
switch(&mut ui, "W69").unwrap();
assert!(ui.control_bar, "the layout just left is kept");
}
/// Workspace names match whatever their case, as `find` does: "Mine" saved over "mine" replaces
/// it, and changes made under one spelling come back under the other.
#[test]
fn workspace_names_match_whatever_their_case() {
let mut ui = UiState::default();
save_as(&mut ui, "mine").unwrap();
save_as(&mut ui, "Mine").unwrap();
assert_eq!(ui.custom_workspaces.len(), 1, "one workspace, not two spellings");
ui.control_bar = true;
switch(&mut ui, ESSENTIALS).unwrap();
assert_eq!(switch(&mut ui, "MINE").unwrap(), "Mine");
assert!(ui.control_bar, "its changes came back");
// A preferences file with a remembered layout under another spelling, twice: it maps to the
// saved one, once.
ui.control_bar = false;
switch(&mut ui, ESSENTIALS).unwrap();
let mut odd = capture(&ui, "MINE");
odd.control_bar = true;
ui.workspace_changes = vec![odd.clone(), Workspace { name: "mine".into(), ..odd }];
let mut ui = ui.sanitized();
assert_eq!(ui.workspace_changes.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(), ["Mine"]);
switch(&mut ui, "Mine").unwrap();
assert!(ui.control_bar);
}
/// Saving a workspace takes the current layout into it, which then starts with no changes.
#[test]
fn saving_a_workspace_moves_the_changes_into_it() {
let mut ui = UiState { control_bar: true, ..Default::default() };
save_as(&mut ui, "Mine").unwrap();
switch(&mut ui, ESSENTIALS).unwrap();
assert!(!ui.control_bar, "Essentials stays as saved: the changes went into Mine");
switch(&mut ui, "Mine").unwrap();
assert!(ui.control_bar);
// Saving again under the same name takes the current layout and starts with no changes.
ui.status_bar = false;
save_as(&mut ui, "Mine").unwrap();
assert!(ui.workspace_changes.iter().all(|w| w.name != "Mine"));
ui.status_bar = true;
assert_eq!(reset(&mut ui), "Mine");
assert!(!ui.status_bar);
}
#[test]
fn builtin_names_are_protected() {
let mut ui = UiState::default();
+5
View File
@@ -858,6 +858,11 @@ positions of hidden panels. Resetting a built-in workspace restores its default
dock placement when possible, or to the supplied destination.
- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved
position when possible. `window.panel.close {panel}` hides it.
- `window.panel.collapse {panel, collapsed?}` shows the dock group holding a panel as its tabs
only, as a double-click on a tab does (without `collapsed` it toggles), and makes the panel the
group's shown one; showing another of its panels expands the group. Only a group that is the
whole dock or sits above or below another collapses (one beside another is refused). Floating
groups collapse with `window.panel.float {panel, collapsed}`. Returns `{panel, collapsed}`.
Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and
invalid destinations return an error without changing the arrangement.
+14
View File
@@ -390,10 +390,24 @@ The original site's plain HTTP archive is no longer fetched or extracted. To ref
review the mirror commit, original-file list, redistribution license, sizes and hashes together;
do not replace expected hashes automatically in response to a download mismatch.
## Highlighted-text Eyedropper (2026-10-21)
Switching from Type to Eyedropper preserves the highlighted character range and its overlay.
Sampling applies enabled font and paint attributes to that range; Shift samples color only.
Returning to Type retains the range; deselection and document changes clear stale ranges.
Character styles cannot represent object opacity or blend modes. With Transparency enabled,
sampling refuses a non-neutral source or target object before changing art, paint defaults,
selection or history. Disable Transparency in Eyedropper Options to sample supported attributes
while preserving the target object's transparency. Whole-object sampling remains unchanged.
Regression evidence: `tests_eyedropper.rs` covers Unicode, font/paint, Undo/Redo, tool switches,
and atomic rejection of source and target opacity/blend modes; `text_range_and_brush_sampling_options_coexist`
covers upstream brush-key sampling. Scores and estimates unchanged.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
| 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's and vectorcraft-mcp's `bundled-fonts` features, and the engine declared without default features. |
| 2026-10-14 | minor | Document the `bundled-fonts` feature of vectorcraft-text and vectorcraft-engine (Fonts › Leaving out the bundled fonts). |
| 2026-10-14 | minor | Document registration of every explicitly supplied craft-fonts face after the existing script-specific prefix; empty input and web selection remain unchanged. |
+27 -4
View File
File diff suppressed because one or more lines are too long
+29 -1
View File
@@ -1349,6 +1349,9 @@ already walks Image Trace. Short agent entry points:
`livePaint.merge` keeps the bottom Live Paint group's id, name, lock, visibility, transparency, appearance, opacity
mask and graphic style link. With a Live Paint group among the objects, `livePaint.make` adds the others to it as
`livePaint.merge` does, and so does `livePaint.fill {ids}` when `point` is outside that group's faces.
`livePaint.fill` does not fill a face that is hidden on its own. With `group`, a point on a hidden face is an
error. With `ids`, at a point on a hidden face it fills the face there of another Live Paint group among the
objects; otherwise it returns an error and does not merge the objects.
`brush.selectionArt {ids?|id?}` (a query) replies `{art}`: the object that `brush.new` given the same `ids` or `id`,
or the same selection, takes as a new scatter or art brush's art and as a new pattern brush's side tile. Several
@@ -1535,7 +1538,13 @@ Recolor Artwork keys colours by identity, not hex: `recolor.colors` lists each c
Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key.
`recolor.reduce {colors?, method?, preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use;
tints of a global swatch share its row; White and Black are preserved by default) and returns a map of rows
`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, limitTo?, group?, groupColors?, rename?}`.
`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, model?, limitTo?, group?, groupColors?, rename?}`.
For scripts and MCP, `model` selects the replacement color model: `keep` (default) retains each
original color's model, `new` takes the replacement's model, and `document` converts replacements
and edited color groups to the document's model. Invalid values fail before editing. This parameter
is not a control in the Recolor Artwork dialog. A returned `note` reports a model conversion in
`keep` mode.
`method` says how a row's colours take its new colour: `exact`, `preserveTints` (tints of the row's darkest colour
stay tints), `scaleTints` (every colour becomes a tint as light, relative to the darkest), `tintsShades` (lighter and
darker than the row's average become tints and shades) or `hueShift` (the most saturated colour takes the new colour,
@@ -4135,6 +4144,25 @@ explicitly, `{}` for none. Each command is one undo step.
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"fontAxes":{"wght":650,"wdth":87.5}}}}
```
## Character Direction
`text.setFormat {charDirection: "default"|"leftToRight"|"rightToLeft"}` (the selected type, or `ids`) and
`text.setRangeStyle {id, start, end, charDirection}` (a range) set the direction of the characters inside their
paragraph, as the Character panel menu's Character Direction does with the Middle Eastern (Indic) options shown. The bidi
algorithm (UAX #9) resolves each stretch of characters with a direction as an embedding in that direction, as if an LRE
or RLE stood before it and a PDF after it: `leftToRight` keeps a date written with spaces, such as `21 10 2026`, in its
order inside a right-to-left paragraph, and `rightToLeft` sets a Hebrew or Arabic word and the punctuation after it right
to left inside a left-to-right paragraph. Characters keep their own strong direction inside an embedding: Latin letters
set Right to Left still read left to right. `default` leaves the characters to the bidi algorithm. The paragraph's own
direction is unchanged. SVG export writes type with a character drawn right to left, or with a run set Right to Left,
as outlines; live SVG text writes `direction="ltr"` and `unicode-bidi="embed"` on each run set Left to Right. Character
Direction is Default until set and is saved only when set, and character styles captured from type carry it. Each
command is one undo step.
```json
{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":9,"end":19,"charDirection":"leftToRight"}}}
```
## Digits
`text.setFormat {digits: "default"|"arabic"|"hindi"|"farsi"}` (the selected type, or `ids`) and `text.setRangeStyle {id,
+21 -5
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22 -3
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
sonar.projectKey=storytold_vectorcraft
sonar.organization=storytold